Compare commits
3 Commits
0183b03bc2
...
53c6fad262
| Author | SHA1 | Date |
|---|---|---|
|
|
53c6fad262 | |
|
|
c97b06e07b | |
|
|
2836d5954e |
|
|
@ -36,6 +36,7 @@ db.sqlite3
|
||||||
db.sqlite3-journal
|
db.sqlite3-journal
|
||||||
/media
|
/media
|
||||||
/staticfiles
|
/staticfiles
|
||||||
|
/local
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
|
||||||
|
|
@ -14,4 +14,9 @@ COPY . /app
|
||||||
# Collect static files for WhiteNoise
|
# Collect static files for WhiteNoise
|
||||||
RUN python manage.py collectstatic --noinput
|
RUN python manage.py collectstatic --noinput
|
||||||
|
|
||||||
|
# Make entrypoint script executable
|
||||||
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
CMD ["gunicorn", "seduttomachineworks_project.wsgi:application", "--bind", "0.0.0.0:8000"]
|
CMD ["gunicorn", "seduttomachineworks_project.wsgi:application", "--bind", "0.0.0.0:8000"]
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ services:
|
||||||
web_upload:
|
web_upload:
|
||||||
image: git.kitsunehosting.net/kenwood/smw-upload:latest
|
image: git.kitsunehosting.net/kenwood/smw-upload:latest
|
||||||
volumes:
|
volumes:
|
||||||
- /var/seduttomachineworks/data:/app/store
|
- /var/seduttomachineworks/data:/app/data
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
environment:
|
environment:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
echo "Running database migrations..."
|
||||||
|
python manage.py migrate --noinput
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
echo "Starting application..."
|
||||||
|
exec "$@"
|
||||||
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
from .models import Submission, SubmissionFile
|
||||||
|
|
||||||
|
|
||||||
|
class SubmissionFileInline(admin.TabularInline):
|
||||||
|
"""Inline admin for submission files."""
|
||||||
|
|
||||||
|
model = SubmissionFile
|
||||||
|
extra = 0
|
||||||
|
readonly_fields = ["uploaded_at", "file_size"]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Submission)
|
||||||
|
class SubmissionAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin interface for submissions."""
|
||||||
|
|
||||||
|
list_display = [
|
||||||
|
"email",
|
||||||
|
"submitted_at",
|
||||||
|
"expiration_time",
|
||||||
|
"is_expired",
|
||||||
|
"file_count",
|
||||||
|
]
|
||||||
|
list_filter = ["submitted_at", "expiration_time"]
|
||||||
|
search_fields = ["email", "description"]
|
||||||
|
readonly_fields = ["submitted_at"]
|
||||||
|
inlines = [SubmissionFileInline]
|
||||||
|
|
||||||
|
def file_count(self, obj):
|
||||||
|
"""Display the number of files in this submission."""
|
||||||
|
return obj.files.count()
|
||||||
|
|
||||||
|
file_count.short_description = "Files"
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(SubmissionFile)
|
||||||
|
class SubmissionFileAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin interface for submission files."""
|
||||||
|
|
||||||
|
list_display = ["original_filename", "submission", "file_size", "uploaded_at"]
|
||||||
|
list_filter = ["uploaded_at"]
|
||||||
|
search_fields = ["original_filename", "submission__email"]
|
||||||
|
readonly_fields = ["uploaded_at", "file_size"]
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class QuotesConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "quotes"
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
"""
|
||||||
|
Email utilities for sending submission notifications.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.core.mail import send_mail, EmailMultiAlternatives
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_urls_with_base_url(files):
|
||||||
|
"""
|
||||||
|
Get absolute URLs for files.
|
||||||
|
Constructs full URLs using ALLOWED_HOSTS from settings.
|
||||||
|
"""
|
||||||
|
# Get base URL from ALLOWED_HOSTS
|
||||||
|
allowed_hosts = getattr(settings, "ALLOWED_HOSTS", [])
|
||||||
|
if allowed_hosts and allowed_hosts[0] != "*":
|
||||||
|
# Use first allowed host
|
||||||
|
domain = allowed_hosts[0]
|
||||||
|
# Check if it's localhost or IP for HTTP
|
||||||
|
if (
|
||||||
|
domain.startswith("127.0.0.1")
|
||||||
|
or domain.startswith("localhost")
|
||||||
|
or domain.startswith("10.")
|
||||||
|
):
|
||||||
|
# In DEBUG mode, add port 8000 (default Django dev server)
|
||||||
|
if settings.DEBUG:
|
||||||
|
base_url = f"http://{domain}:8000"
|
||||||
|
else:
|
||||||
|
base_url = f"http://{domain}"
|
||||||
|
else:
|
||||||
|
base_url = f"https://{domain}"
|
||||||
|
else:
|
||||||
|
# Fallback: use localhost with port in DEBUG mode
|
||||||
|
if settings.DEBUG:
|
||||||
|
base_url = "http://127.0.0.1:8000"
|
||||||
|
else:
|
||||||
|
base_url = ""
|
||||||
|
|
||||||
|
file_urls = []
|
||||||
|
for file in files:
|
||||||
|
# In DEBUG mode, use MEDIA_URL (WhiteNoise serves it)
|
||||||
|
# In production, use /uploads/ (nginx serves it)
|
||||||
|
if settings.DEBUG:
|
||||||
|
# Use MEDIA_URL which is "media/"
|
||||||
|
media_url = settings.MEDIA_URL.rstrip("/")
|
||||||
|
file_path = f"/{media_url}/{file.path}"
|
||||||
|
else:
|
||||||
|
# Use /uploads/ for nginx
|
||||||
|
file_path = f"/uploads/{file.path}"
|
||||||
|
|
||||||
|
if base_url:
|
||||||
|
# Make it an absolute URL
|
||||||
|
file_url = base_url + file_path
|
||||||
|
else:
|
||||||
|
# Fallback: relative URL
|
||||||
|
file_url = file_path
|
||||||
|
|
||||||
|
file_urls.append(
|
||||||
|
{
|
||||||
|
"file": file,
|
||||||
|
"url": file_url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return file_urls
|
||||||
|
|
||||||
|
|
||||||
|
def send_submission_emails(submission):
|
||||||
|
"""
|
||||||
|
Send emails for a quote submission.
|
||||||
|
Sends:
|
||||||
|
1. Notification to owner (OWNER_EMAIL)
|
||||||
|
2. Confirmation to submitter (submission.email)
|
||||||
|
|
||||||
|
Returns tuple (owner_sent, submitter_sent)
|
||||||
|
"""
|
||||||
|
files = submission.files.all()
|
||||||
|
file_urls = get_file_urls_with_base_url(files)
|
||||||
|
owner_email = getattr(settings, "OWNER_EMAIL", "")
|
||||||
|
|
||||||
|
owner_sent = False
|
||||||
|
submitter_sent = False
|
||||||
|
|
||||||
|
# Email 1: Notification to owner
|
||||||
|
if owner_email:
|
||||||
|
try:
|
||||||
|
subject = f"New Quote Request - Submission #{submission.id}"
|
||||||
|
html_message = render_to_string(
|
||||||
|
"emails/submission_notification_owner.html",
|
||||||
|
{
|
||||||
|
"submission": submission,
|
||||||
|
"files": files,
|
||||||
|
"file_urls": file_urls,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Plain text fallback
|
||||||
|
text_message = f"""
|
||||||
|
New Quote Request Received!
|
||||||
|
|
||||||
|
Submitted by: {submission.email}
|
||||||
|
Submitted at: {submission.submitted_at.strftime('%B %d, %Y %I:%M %p')}
|
||||||
|
Submission ID: #{submission.id}
|
||||||
|
|
||||||
|
Description:
|
||||||
|
{submission.description or '(No description provided)'}
|
||||||
|
|
||||||
|
Files uploaded: {files.count()}
|
||||||
|
"""
|
||||||
|
for file_url_data in file_urls:
|
||||||
|
file = file_url_data["file"]
|
||||||
|
url = file_url_data["url"]
|
||||||
|
text_message += f"- {file.original_filename} ({file.file_size} bytes)\n"
|
||||||
|
text_message += f" Download: {url}\n"
|
||||||
|
|
||||||
|
msg = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=text_message,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
to=[owner_email],
|
||||||
|
)
|
||||||
|
msg.attach_alternative(html_message, "text/html")
|
||||||
|
msg.send()
|
||||||
|
owner_sent = True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error sending owner email: {e}")
|
||||||
|
|
||||||
|
# Email 2: Confirmation to submitter
|
||||||
|
try:
|
||||||
|
subject = "Quote Request Received - Sedutto Machineworks"
|
||||||
|
html_message = render_to_string(
|
||||||
|
"emails/submission_confirmation.html",
|
||||||
|
{
|
||||||
|
"submission": submission,
|
||||||
|
"files": files,
|
||||||
|
"file_urls": file_urls,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Plain text fallback
|
||||||
|
text_message = f"""
|
||||||
|
Thank you for your quote request!
|
||||||
|
|
||||||
|
We have received your submission and will review it shortly.
|
||||||
|
|
||||||
|
Submission Details:
|
||||||
|
Submission ID: #{submission.id}
|
||||||
|
Submitted at: {submission.submitted_at.strftime('%B %d, %Y %I:%M %p')}
|
||||||
|
|
||||||
|
Your Notes:
|
||||||
|
{submission.description or '(No notes provided)'}
|
||||||
|
|
||||||
|
Files uploaded: {files.count()}
|
||||||
|
"""
|
||||||
|
for file_url_data in file_urls:
|
||||||
|
file = file_url_data["file"]
|
||||||
|
url = file_url_data["url"]
|
||||||
|
text_message += f"- {file.original_filename} ({file.file_size} bytes)\n"
|
||||||
|
text_message += f" Download: {url}\n"
|
||||||
|
|
||||||
|
text_message += f"\nWe will contact you at {submission.email} regarding your quote request.\n"
|
||||||
|
text_message += f"\nThis submission will be kept on file until {submission.expiration_time.strftime('%B %d, %Y')}."
|
||||||
|
|
||||||
|
msg = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=text_message,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
to=[submission.email],
|
||||||
|
)
|
||||||
|
msg.attach_alternative(html_message, "text/html")
|
||||||
|
msg.send()
|
||||||
|
submitter_sent = True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error sending submitter email: {e}")
|
||||||
|
|
||||||
|
return (owner_sent, submitter_sent)
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
# Generated by Django 4.2.27 on 2025-12-03 23:22
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import quotes.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Submission",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"email",
|
||||||
|
models.EmailField(
|
||||||
|
help_text="Email of the person who submitted the quote request",
|
||||||
|
max_length=254,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"description",
|
||||||
|
models.TextField(
|
||||||
|
help_text="Notes, comments, or description provided by the submitter"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"submitted_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now_add=True, help_text="When the submission was created"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"expiration_time",
|
||||||
|
models.DateTimeField(
|
||||||
|
default=quotes.models.default_expiration_time,
|
||||||
|
help_text="When this submission expires (default 20 days from submission)",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Submission",
|
||||||
|
"verbose_name_plural": "Submissions",
|
||||||
|
"ordering": ["-submitted_at"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="SubmissionFile",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"original_filename",
|
||||||
|
models.CharField(
|
||||||
|
help_text="Original filename as uploaded by the user",
|
||||||
|
max_length=255,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"path",
|
||||||
|
models.CharField(
|
||||||
|
help_text="Path relative to /data/uploads (e.g., 'filename.pdf')",
|
||||||
|
max_length=500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"file_size",
|
||||||
|
models.PositiveIntegerField(help_text="Size of the file in bytes"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uploaded_at",
|
||||||
|
models.DateTimeField(
|
||||||
|
auto_now_add=True, help_text="When the file was uploaded"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"submission",
|
||||||
|
models.ForeignKey(
|
||||||
|
help_text="The submission this file belongs to",
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="files",
|
||||||
|
to="quotes.submission",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Submission File",
|
||||||
|
"verbose_name_plural": "Submission Files",
|
||||||
|
"ordering": ["uploaded_at"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def default_expiration_time():
|
||||||
|
"""Default expiration time: 20 days from now."""
|
||||||
|
return timezone.now() + timedelta(days=20)
|
||||||
|
|
||||||
|
|
||||||
|
class Submission(models.Model):
|
||||||
|
"""Model for quote request submissions."""
|
||||||
|
|
||||||
|
email = models.EmailField(
|
||||||
|
help_text="Email of the person who submitted the quote request"
|
||||||
|
)
|
||||||
|
description = models.TextField(
|
||||||
|
help_text="Notes, comments, or description provided by the submitter"
|
||||||
|
)
|
||||||
|
submitted_at = models.DateTimeField(
|
||||||
|
auto_now_add=True, help_text="When the submission was created"
|
||||||
|
)
|
||||||
|
expiration_time = models.DateTimeField(
|
||||||
|
default=default_expiration_time,
|
||||||
|
help_text="When this submission expires (default 20 days from submission)",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-submitted_at"]
|
||||||
|
verbose_name = "Submission"
|
||||||
|
verbose_name_plural = "Submissions"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Submission from {self.email} at {self.submitted_at.strftime('%Y-%m-%d %H:%M')}"
|
||||||
|
|
||||||
|
def is_expired(self):
|
||||||
|
"""Check if the submission has expired."""
|
||||||
|
return timezone.now() > self.expiration_time
|
||||||
|
|
||||||
|
def get_file_paths(self):
|
||||||
|
"""
|
||||||
|
Get list of all file paths relative to /data/uploads.
|
||||||
|
Returns list of relative paths as strings.
|
||||||
|
"""
|
||||||
|
return [file.path for file in self.files.all()]
|
||||||
|
|
||||||
|
def get_file_urls(self):
|
||||||
|
"""
|
||||||
|
Get list of URLs for all files in this submission.
|
||||||
|
Returns list of URLs that can be used to access the files.
|
||||||
|
"""
|
||||||
|
from django.conf import settings
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
urls = []
|
||||||
|
for file in self.files.all():
|
||||||
|
# Path relative to MEDIA_ROOT
|
||||||
|
relative_path = file.path
|
||||||
|
# Construct URL
|
||||||
|
url = f"{settings.MEDIA_URL}{relative_path}"
|
||||||
|
urls.append(url)
|
||||||
|
return urls
|
||||||
|
|
||||||
|
def get_absolute_file_paths(self):
|
||||||
|
"""
|
||||||
|
Get list of absolute file system paths for all files.
|
||||||
|
Returns list of absolute Path objects.
|
||||||
|
"""
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
paths = []
|
||||||
|
for file in self.files.all():
|
||||||
|
# Path relative to MEDIA_ROOT/uploads
|
||||||
|
relative_path = file.path
|
||||||
|
# Construct absolute path
|
||||||
|
absolute_path = Path(settings.MEDIA_ROOT) / relative_path
|
||||||
|
paths.append(absolute_path)
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
class SubmissionFile(models.Model):
|
||||||
|
"""Model for files associated with a submission."""
|
||||||
|
|
||||||
|
submission = models.ForeignKey(
|
||||||
|
Submission,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="files",
|
||||||
|
help_text="The submission this file belongs to",
|
||||||
|
)
|
||||||
|
original_filename = models.CharField(
|
||||||
|
max_length=255, help_text="Original filename as uploaded by the user"
|
||||||
|
)
|
||||||
|
path = models.CharField(
|
||||||
|
max_length=500,
|
||||||
|
help_text="Path relative to /data/uploads (e.g., 'filename.pdf')",
|
||||||
|
)
|
||||||
|
file_size = models.PositiveIntegerField(help_text="Size of the file in bytes")
|
||||||
|
uploaded_at = models.DateTimeField(
|
||||||
|
auto_now_add=True, help_text="When the file was uploaded"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["uploaded_at"]
|
||||||
|
verbose_name = "Submission File"
|
||||||
|
verbose_name_plural = "Submission Files"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.original_filename} ({self.submission.email})"
|
||||||
|
|
||||||
|
def get_absolute_path(self):
|
||||||
|
"""Get the absolute file system path."""
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
return Path(settings.MEDIA_ROOT) / self.path
|
||||||
|
|
||||||
|
def get_url(self):
|
||||||
|
"""Get the URL to access this file."""
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
return f"{settings.MEDIA_URL}{self.path}"
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
"""
|
||||||
|
Email configuration parser and utilities.
|
||||||
|
Parses EMAIL_CONFIG environment variable in format:
|
||||||
|
smtp://<user>@kitsunehosting.net:<password>@smtp.forwardemail.net:465
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from urllib.parse import urlparse, unquote
|
||||||
|
|
||||||
|
|
||||||
|
def parse_email_config(email_config_str):
|
||||||
|
"""
|
||||||
|
Parse EMAIL_CONFIG string into email settings.
|
||||||
|
|
||||||
|
Format: smtp://<user>@kitsunehosting.net:<password>@smtp.forwardemail.net:465
|
||||||
|
|
||||||
|
Returns dict with: user, password, host, port, from_email
|
||||||
|
"""
|
||||||
|
if not email_config_str:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Parse the URL
|
||||||
|
# Format: smtp://user@domain:password@host:port
|
||||||
|
# We need to handle the @ symbols carefully
|
||||||
|
match = re.match(r"smtp://([^:]+):([^@]+)@([^:]+):(\d+)", email_config_str)
|
||||||
|
if not match:
|
||||||
|
raise ValueError(f"Invalid EMAIL_CONFIG format: {email_config_str}")
|
||||||
|
|
||||||
|
user_with_domain = match.group(1)
|
||||||
|
password = unquote(match.group(2))
|
||||||
|
host = match.group(3)
|
||||||
|
port = int(match.group(4))
|
||||||
|
|
||||||
|
# Extract email address (user@domain)
|
||||||
|
from_email = user_with_domain
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user": user_with_domain,
|
||||||
|
"password": password,
|
||||||
|
"host": host,
|
||||||
|
"port": port,
|
||||||
|
"from_email": from_email,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_email_config():
|
||||||
|
"""Get email configuration from environment."""
|
||||||
|
email_config_str = os.environ.get("EMAIL_CONFIG")
|
||||||
|
if not email_config_str:
|
||||||
|
return None
|
||||||
|
return parse_email_config(email_config_str)
|
||||||
|
|
@ -3,6 +3,8 @@ import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from django.http import StreamingHttpResponse, JsonResponse
|
from django.http import StreamingHttpResponse, JsonResponse
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.validators import validate_email
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.views.decorators.clickjacking import xframe_options_exempt
|
from django.views.decorators.clickjacking import xframe_options_exempt
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
|
||||||
|
|
@ -33,40 +35,83 @@ def submit_quote(request):
|
||||||
|
|
||||||
def process_quote():
|
def process_quote():
|
||||||
try:
|
try:
|
||||||
if not settings.DEBUG:
|
# Step 1: Validate email
|
||||||
raise Exception("Not implemented")
|
yield send_progress_update(1, "Validating email address...", 5)
|
||||||
|
|
||||||
# Step 1: Upload files
|
if not email:
|
||||||
yield send_progress_update(1, "Uploading files...", 25)
|
raise ValueError("Email is required")
|
||||||
# Files are already uploaded at this point, just acknowledge
|
|
||||||
uploaded_files = []
|
|
||||||
for file in files:
|
|
||||||
uploaded_files.append({"name": file.name, "size": file.size})
|
|
||||||
|
|
||||||
# Step 2: Save files
|
try:
|
||||||
yield send_progress_update(2, "Saving files...", 50)
|
validate_email(email)
|
||||||
# Save files to disk/storage
|
except ValidationError:
|
||||||
saved_paths = []
|
raise ValueError(f"Invalid email address: {email}")
|
||||||
for file in files:
|
|
||||||
# Create store directory if it doesn't exist
|
|
||||||
store_dir = Path(settings.MEDIA_ROOT) / "uploads"
|
|
||||||
store_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Save file
|
yield send_progress_update(1, "Email validated successfully", 10)
|
||||||
file_path = store_dir / file.name
|
|
||||||
with open(file_path, "wb") as f:
|
# Step 2: Create submission & upload files
|
||||||
|
from quotes.models import Submission, SubmissionFile
|
||||||
|
|
||||||
|
# Create a Submission entry
|
||||||
|
submission = Submission.objects.create(
|
||||||
|
email=email,
|
||||||
|
description=notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
yield send_progress_update(2, "Created submission entry...", 15)
|
||||||
|
|
||||||
|
# Save each file and register in the model
|
||||||
|
total_files = len(files)
|
||||||
|
file_objs = []
|
||||||
|
for idx, file in enumerate(files, start=1):
|
||||||
|
# Use the original filename and save to MEDIA_ROOT/submission_<id>_filename
|
||||||
|
submission_dir = (
|
||||||
|
Path(settings.MEDIA_ROOT) / f"submission_{submission.id}"
|
||||||
|
)
|
||||||
|
submission_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
target_path = submission_dir / file.name
|
||||||
|
|
||||||
|
with open(target_path, "wb") as f:
|
||||||
for chunk in file.chunks():
|
for chunk in file.chunks():
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
saved_paths.append(str(file_path))
|
|
||||||
|
|
||||||
# Step 3: Send emails
|
# Register file in SubmissionFile model
|
||||||
yield send_progress_update(3, "Sending emails 1/2...", 70)
|
rel_path = (Path(f"submission_{submission.id}") / file.name).as_posix()
|
||||||
time.sleep(0.5)
|
submission_file = SubmissionFile.objects.create(
|
||||||
# TODO: Actually send emails
|
submission=submission,
|
||||||
yield send_progress_update(4, "Sending emails 2/2...", 75)
|
original_filename=file.name,
|
||||||
time.sleep(0.5)
|
path=rel_path,
|
||||||
# TODO: Actually send emails
|
file_size=file.size,
|
||||||
emails_sent = True # Placeholder
|
)
|
||||||
|
file_objs.append(submission_file)
|
||||||
|
yield send_progress_update(
|
||||||
|
2,
|
||||||
|
f"Uploaded file {idx} of {total_files}: {file.name}",
|
||||||
|
15 + int((idx / total_files) * 30),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield send_progress_update(3, "Files saved...", 50)
|
||||||
|
|
||||||
|
# Step 4: Send emails
|
||||||
|
from quotes.email_utils import send_submission_emails
|
||||||
|
|
||||||
|
yield send_progress_update(4, "Sending notification email...", 70)
|
||||||
|
owner_sent, submitter_sent = send_submission_emails(submission)
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
yield send_progress_update(4, "Sending confirmation email...", 75)
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
# Verify emails were sent
|
||||||
|
if not owner_sent and getattr(settings, "OWNER_EMAIL", ""):
|
||||||
|
yield send_progress_update(
|
||||||
|
"warning",
|
||||||
|
"Warning: Owner notification email may not have been sent",
|
||||||
|
75,
|
||||||
|
)
|
||||||
|
if not submitter_sent:
|
||||||
|
yield send_progress_update(
|
||||||
|
"warning", "Warning: Confirmation email may not have been sent", 75
|
||||||
|
)
|
||||||
|
|
||||||
# Complete
|
# Complete
|
||||||
yield send_progress_update(
|
yield send_progress_update(
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ INSTALLED_APPS = [
|
||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
|
"quotes",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|
@ -63,10 +64,23 @@ WSGI_APPLICATION = "seduttomachineworks_project.wsgi.application"
|
||||||
# Database
|
# Database
|
||||||
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
|
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
|
||||||
|
|
||||||
|
# Use local/data directory in DEBUG mode, otherwise use /app/data
|
||||||
|
if DEBUG:
|
||||||
|
DATA_DIR = BASE_DIR / "local" / "data"
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": {
|
"default": {
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
"NAME": BASE_DIR / "db.sqlite3",
|
"NAME": DATA_DIR / "db.sqlite3",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
DATA_DIR = Path("/app/data")
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
DATABASES = {
|
||||||
|
"default": {
|
||||||
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
|
"NAME": DATA_DIR / "db.sqlite3",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,7 +90,7 @@ DATABASES = {
|
||||||
|
|
||||||
LANGUAGE_CODE = "en-us"
|
LANGUAGE_CODE = "en-us"
|
||||||
|
|
||||||
TIME_ZONE = "UTC"
|
TIME_ZONE = "America/New_York"
|
||||||
|
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
|
|
||||||
|
|
@ -94,7 +108,17 @@ STATICFILES_DIRS = [BASE_DIR / "static"]
|
||||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||||
|
|
||||||
MEDIA_URL = "media/"
|
MEDIA_URL = "media/"
|
||||||
MEDIA_ROOT = BASE_DIR / "media"
|
# Use local/data/uploads in DEBUG mode, otherwise use /app/data/uploads
|
||||||
|
if DEBUG:
|
||||||
|
DATA_DIR = BASE_DIR / "local" / "data"
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
MEDIA_ROOT = DATA_DIR / "uploads"
|
||||||
|
MEDIA_ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
|
else:
|
||||||
|
DATA_DIR = Path("/app/data")
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
MEDIA_ROOT = DATA_DIR / "uploads"
|
||||||
|
MEDIA_ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Default primary key field type
|
# Default primary key field type
|
||||||
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
|
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
|
||||||
|
|
@ -103,3 +127,29 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
# Allow iframe embedding (for the quoting upload box)
|
# Allow iframe embedding (for the quoting upload box)
|
||||||
# Note: We'll handle this per-view using @xframe_options_exempt decorator
|
# Note: We'll handle this per-view using @xframe_options_exempt decorator
|
||||||
|
|
||||||
|
# Email configuration
|
||||||
|
EMAIL_CONFIG = None
|
||||||
|
try:
|
||||||
|
from .email_config import get_email_config
|
||||||
|
|
||||||
|
email_config = get_email_config()
|
||||||
|
if email_config:
|
||||||
|
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||||
|
EMAIL_HOST = email_config["host"]
|
||||||
|
EMAIL_PORT = email_config["port"]
|
||||||
|
EMAIL_USE_SSL = True # Port 465 uses SSL
|
||||||
|
EMAIL_HOST_USER = email_config["user"]
|
||||||
|
EMAIL_HOST_PASSWORD = email_config["password"]
|
||||||
|
DEFAULT_FROM_EMAIL = email_config["from_email"]
|
||||||
|
SERVER_EMAIL = email_config["from_email"]
|
||||||
|
EMAIL_CONFIG = email_config
|
||||||
|
else:
|
||||||
|
# Fallback to console backend for development
|
||||||
|
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||||
|
except Exception as e:
|
||||||
|
# If email config fails, use console backend
|
||||||
|
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||||
|
|
||||||
|
# Owner email for notifications
|
||||||
|
OWNER_EMAIL = os.environ.get("OWNER_EMAIL", "")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background-color: #0066cc;
|
||||||
|
color: white;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 5px 5px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box {
|
||||||
|
background-color: white;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 15px 0;
|
||||||
|
border-left: 4px solid #0066cc;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list li {
|
||||||
|
padding: 8px;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
margin: 5px 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list li a {
|
||||||
|
color: #0066cc;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list li a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>Quote Request Received</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Thank you for your quote request! We have received your submission and will review it shortly.</p>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<strong>Submission Details:</strong><br>
|
||||||
|
<strong>Submission ID:</strong> #{{ submission.id }}<br>
|
||||||
|
<strong>Submitted at:</strong> {{ submission.submitted_at|date:"F d, Y g:i A" }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if submission.description %}
|
||||||
|
<div class="info-box">
|
||||||
|
<strong>Your Notes:</strong><br>
|
||||||
|
{{ submission.description|linebreaks }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if files %}
|
||||||
|
<div class="info-box">
|
||||||
|
<strong>Files You Uploaded ({{ files|length }}):</strong>
|
||||||
|
<ul class="file-list">
|
||||||
|
{% for file_url_data in file_urls %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ file_url_data.url }}" target="_blank">{{ file_url_data.file.original_filename }}</a>
|
||||||
|
({{ file_url_data.file.file_size|filesizeformat }})
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p>We will contact you at <strong>{{ submission.email }}</strong> regarding your quote request.</p>
|
||||||
|
|
||||||
|
<p><em>This submission will be kept on file until {{ submission.expiration_time|date:"F d, Y" }}.</em></p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>Sedutto Machineworks</p>
|
||||||
|
<p>Thank you for your interest in our services!</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background-color: #0066cc;
|
||||||
|
color: white;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 5px 5px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box {
|
||||||
|
background-color: white;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 15px 0;
|
||||||
|
border-left: 4px solid #0066cc;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list li {
|
||||||
|
padding: 8px;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
margin: 5px 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list li a {
|
||||||
|
color: #0066cc;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list li a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>New Quote Request Submission</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>HUZZAHH! A new quote request has been submitted!</p>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<strong>Submitted by:</strong> {{ submission.email }}<br>
|
||||||
|
<strong>Submitted at:</strong> {{ submission.submitted_at|date:"F d, Y g:i A" }}<br>
|
||||||
|
<strong>Submission ID:</strong> #{{ submission.id }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if submission.description %}
|
||||||
|
<div class="info-box">
|
||||||
|
<strong>Description/Notes:</strong><br>
|
||||||
|
{{ submission.description|linebreaks }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if files %}
|
||||||
|
<div class="info-box">
|
||||||
|
<strong>Uploaded Files ({{ files|length }}):</strong>
|
||||||
|
<ul class="file-list">
|
||||||
|
{% for file_url_data in file_urls %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ file_url_data.url }}" target="_blank">{{ file_url_data.file.original_filename }}</a>
|
||||||
|
({{ file_url_data.file.file_size|filesizeformat }})
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p><strong>Note:</strong> This submission will expire on {{ submission.expiration_time|date:"F d, Y g:i A" }}.
|
||||||
|
(But who knows when joe will actually delete it)</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>Sedutto Machineworks - Joe is tottally awesome</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue