diff --git a/.gitignore b/.gitignore index 3669209..ef4e2ac 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ db.sqlite3 db.sqlite3-journal /media /staticfiles +/local # IDE .vscode/ diff --git a/README.md b/README.md index 90838c8..7adccf3 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ services: web_upload: image: git.kitsunehosting.net/kenwood/smw-upload:latest volumes: - - /var/seduttomachineworks/data:/app/store + - /var/seduttomachineworks/data:/app/data ports: - "8000:8000" environment: diff --git a/quotes/__init__.py b/quotes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/quotes/admin.py b/quotes/admin.py new file mode 100644 index 0000000..f67e882 --- /dev/null +++ b/quotes/admin.py @@ -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"] diff --git a/quotes/apps.py b/quotes/apps.py new file mode 100644 index 0000000..292317b --- /dev/null +++ b/quotes/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class QuotesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "quotes" diff --git a/quotes/email_utils.py b/quotes/email_utils.py new file mode 100644 index 0000000..aa23c07 --- /dev/null +++ b/quotes/email_utils.py @@ -0,0 +1,109 @@ +""" +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 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() + 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, + }, + ) + + # 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 in files: + text_message += f"- {file.original_filename} ({file.file_size} bytes)\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, + }, + ) + + # 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 in files: + text_message += f"- {file.original_filename} ({file.file_size} bytes)\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) diff --git a/quotes/migrations/0001_initial.py b/quotes/migrations/0001_initial.py new file mode 100644 index 0000000..ef07702 --- /dev/null +++ b/quotes/migrations/0001_initial.py @@ -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"], + }, + ), + ] diff --git a/quotes/migrations/__init__.py b/quotes/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/quotes/models.py b/quotes/models.py new file mode 100644 index 0000000..39035c0 --- /dev/null +++ b/quotes/models.py @@ -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}" diff --git a/seduttomachineworks_project/email_config.py b/seduttomachineworks_project/email_config.py new file mode 100644 index 0000000..4543ea0 --- /dev/null +++ b/seduttomachineworks_project/email_config.py @@ -0,0 +1,52 @@ +""" +Email configuration parser and utilities. +Parses EMAIL_CONFIG environment variable in format: +smtp://@kitsunehosting.net:@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://@kitsunehosting.net:@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) diff --git a/seduttomachineworks_project/quote_submit.py b/seduttomachineworks_project/quote_submit.py index a44034b..2627303 100644 --- a/seduttomachineworks_project/quote_submit.py +++ b/seduttomachineworks_project/quote_submit.py @@ -3,6 +3,8 @@ import time from pathlib import Path from django.http import StreamingHttpResponse, JsonResponse 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.csrf import csrf_exempt @@ -33,40 +35,83 @@ def submit_quote(request): def process_quote(): try: - if not settings.DEBUG: - raise Exception("Not implemented") + # Step 1: Validate email + yield send_progress_update(1, "Validating email address...", 5) - # Step 1: Upload files - yield send_progress_update(1, "Uploading files...", 25) - # Files are already uploaded at this point, just acknowledge - uploaded_files = [] - for file in files: - uploaded_files.append({"name": file.name, "size": file.size}) + if not email: + raise ValueError("Email is required") - # Step 2: Save files - yield send_progress_update(2, "Saving files...", 50) - # Save files to disk/storage - saved_paths = [] - 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) + try: + validate_email(email) + except ValidationError: + raise ValueError(f"Invalid email address: {email}") - # Save file - file_path = store_dir / file.name - with open(file_path, "wb") as f: + yield send_progress_update(1, "Email validated successfully", 10) + + # 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__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(): f.write(chunk) - saved_paths.append(str(file_path)) - # Step 3: Send emails - yield send_progress_update(3, "Sending emails 1/2...", 70) - time.sleep(0.5) - # TODO: Actually send emails - yield send_progress_update(4, "Sending emails 2/2...", 75) - time.sleep(0.5) - # TODO: Actually send emails - emails_sent = True # Placeholder + # Register file in SubmissionFile model + rel_path = (Path(f"submission_{submission.id}") / file.name).as_posix() + submission_file = SubmissionFile.objects.create( + submission=submission, + original_filename=file.name, + path=rel_path, + file_size=file.size, + ) + 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 yield send_progress_update( diff --git a/seduttomachineworks_project/settings.py b/seduttomachineworks_project/settings.py index 2c56b64..7ceb718 100644 --- a/seduttomachineworks_project/settings.py +++ b/seduttomachineworks_project/settings.py @@ -28,6 +28,7 @@ INSTALLED_APPS = [ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", + "quotes", ] MIDDLEWARE = [ @@ -63,12 +64,25 @@ WSGI_APPLICATION = "seduttomachineworks_project.wsgi.application" # Database # https://docs.djangoproject.com/en/4.2/ref/settings/#databases -DATABASES = { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "db.sqlite3", +# 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 = { + "default": { + "ENGINE": "django.db.backends.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", + } } -} # Internationalization @@ -76,7 +90,7 @@ DATABASES = { LANGUAGE_CODE = "en-us" -TIME_ZONE = "UTC" +TIME_ZONE = "America/New_York" USE_I18N = True @@ -94,7 +108,17 @@ STATICFILES_DIRS = [BASE_DIR / "static"] STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" 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 # 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) # 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", "") diff --git a/templates/emails/submission_confirmation.html b/templates/emails/submission_confirmation.html new file mode 100644 index 0000000..94349ad --- /dev/null +++ b/templates/emails/submission_confirmation.html @@ -0,0 +1,104 @@ + + + + + + + + + +
+

Quote Request Received

+
+
+

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|date:"F d, Y g:i A" }} +
+ + {% if submission.description %} +
+ Your Notes:
+ {{ submission.description|linebreaks }} +
+ {% endif %} + + {% if files %} +
+ Files You Uploaded ({{ files|length }}): +
    + {% for file in files %} +
  • {{ file.original_filename }} ({{ file.file_size|filesizeformat }})
  • + {% endfor %} +
+
+ {% endif %} + +

We will contact you at {{ submission.email }} regarding your quote request.

+ +

This submission will be kept on file until {{ submission.expiration_time|date:"F d, Y" }}.

+
+ + + + \ No newline at end of file diff --git a/templates/emails/submission_notification_owner.html b/templates/emails/submission_notification_owner.html new file mode 100644 index 0000000..495e209 --- /dev/null +++ b/templates/emails/submission_notification_owner.html @@ -0,0 +1,102 @@ + + + + + + + + + +
+

New Quote Request Submission

+
+
+

HUZZAHH! A new quote request has been submitted!

+ +
+ Submitted by: {{ submission.email }}
+ Submitted at: {{ submission.submitted_at|date:"F d, Y g:i A" }}
+ Submission ID: #{{ submission.id }} +
+ + {% if submission.description %} +
+ Description/Notes:
+ {{ submission.description|linebreaks }} +
+ {% endif %} + + {% if files %} +
+ Uploaded Files ({{ files|length }}): +
    + {% for file in files %} +
  • {{ file.original_filename }} ({{ file.file_size|filesizeformat }})
  • + {% endfor %} +
+
+ {% endif %} + +

Note: This submission will expire on {{ submission.expiration_time|date:"F d, Y g:i A" }}. + (But who knows when joe will actually delete it)

+
+ + + + \ No newline at end of file