Compare commits

...

7 Commits

Author SHA1 Message Date
KenwoodFox
3e4022e1d5 Add webhook trigger 2025-12-10 22:15:06 -05:00
KenwoodFox
d9208e277c Add fixed nginx conf and upload tweaks 2025-12-03 19:05:11 -05:00
KenwoodFox
53c6fad262 Add entrypoint to run migrations at start 2025-12-03 18:54:30 -05:00
KenwoodFox
c97b06e07b Fix last emails 2025-12-03 18:54:02 -05:00
KenwoodFox
2836d5954e Email config, email template and more! 2025-12-03 18:46:48 -05:00
KenwoodFox
0183b03bc2 Whoops dont confuse customers 2025-12-03 13:35:58 -05:00
KenwoodFox
4cdd902b2e Boilerplate yield and back and forth 2025-12-03 13:31:25 -05:00
26 changed files with 1682 additions and 93 deletions

1
.gitignore vendored
View File

@@ -36,6 +36,7 @@ db.sqlite3
db.sqlite3-journal
/media
/staticfiles
/local
# IDE
.vscode/

View File

@@ -14,4 +14,9 @@ COPY . /app
# Collect static files for WhiteNoise
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"]

View File

@@ -5,6 +5,8 @@ name = "pypi"
[packages]
django = "<5.0,>=4.2"
whitenoise = "<7.0,>=6.0"
requests = "<3.0,>=2.31"
[dev-packages]

181
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "bd4cb91114d0c833ac47abdad00410896c666f4553053870c3c7655342c703a0"
"sha256": "7a4ed5cb168d39157da8b0d77840ffb125ecbdf2c71eb5cdc25a4548c9047bb8"
},
"pipfile-spec": 6,
"requires": {
@@ -18,28 +18,189 @@
"default": {
"asgiref": {
"hashes": [
"sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734",
"sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e"
"sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4",
"sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d"
],
"markers": "python_version >= '3.9'",
"version": "==3.10.0"
"version": "==3.11.0"
},
"certifi": {
"hashes": [
"sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b",
"sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"
],
"markers": "python_version >= '3.7'",
"version": "==2025.11.12"
},
"charset-normalizer": {
"hashes": [
"sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad",
"sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93",
"sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394",
"sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89",
"sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc",
"sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86",
"sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63",
"sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d",
"sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f",
"sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8",
"sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0",
"sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505",
"sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161",
"sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af",
"sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152",
"sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318",
"sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72",
"sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4",
"sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e",
"sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3",
"sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576",
"sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c",
"sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1",
"sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8",
"sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1",
"sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2",
"sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44",
"sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26",
"sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88",
"sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016",
"sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede",
"sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf",
"sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a",
"sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc",
"sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0",
"sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84",
"sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db",
"sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1",
"sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7",
"sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed",
"sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8",
"sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133",
"sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e",
"sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef",
"sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14",
"sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2",
"sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0",
"sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d",
"sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828",
"sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f",
"sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf",
"sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6",
"sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328",
"sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090",
"sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa",
"sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381",
"sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c",
"sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb",
"sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc",
"sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a",
"sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec",
"sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc",
"sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac",
"sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e",
"sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313",
"sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569",
"sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3",
"sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d",
"sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525",
"sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894",
"sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3",
"sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9",
"sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a",
"sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9",
"sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14",
"sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25",
"sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50",
"sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf",
"sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1",
"sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3",
"sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac",
"sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e",
"sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815",
"sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c",
"sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6",
"sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6",
"sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e",
"sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4",
"sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84",
"sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69",
"sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15",
"sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191",
"sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0",
"sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897",
"sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd",
"sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2",
"sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794",
"sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d",
"sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074",
"sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3",
"sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224",
"sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838",
"sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a",
"sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d",
"sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d",
"sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f",
"sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8",
"sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490",
"sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966",
"sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9",
"sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3",
"sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e",
"sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"
],
"markers": "python_version >= '3.7'",
"version": "==3.4.4"
},
"django": {
"hashes": [
"sha256:9398e487bcb55e3f142cb56d19fbd9a83e15bb03a97edc31f408361ee76d9d7a",
"sha256:c96e64fc3c359d051a6306871bd26243db1bd02317472a62ffdbe6c3cae14280"
"sha256:b865fbe0f4a3d1ee36594c5efa42b20db3c8bbb10dff0736face1c6e4bda5b92",
"sha256:f393a394053713e7d213984555c5b7d3caeee78b2ccb729888a0774dff6c11a8"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==4.2.26"
"version": "==4.2.27"
},
"idna": {
"hashes": [
"sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea",
"sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"
],
"markers": "python_version >= '3.8'",
"version": "==3.11"
},
"requests": {
"hashes": [
"sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6",
"sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==2.32.5"
},
"sqlparse": {
"hashes": [
"sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272",
"sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca"
"sha256:4396a7d3cf1cd679c1be976cf3dc6e0a51d0111e87787e7a8d780e7d5a998f9e",
"sha256:99a9f0314977b76d776a0fcb8554de91b9bb8a18560631d6bc48721d07023dcb"
],
"markers": "python_version >= '3.8'",
"version": "==0.5.3"
"version": "==0.5.4"
},
"urllib3": {
"hashes": [
"sha256:5379eb6e1aba4088bae84f8242960017ec8d8e3decf30480b3a1abdaa9671a3f",
"sha256:e67d06fe947c36a7ca39f4994b08d73922d40e6cca949907be05efa6fd75110b"
],
"markers": "python_version >= '3.9'",
"version": "==2.6.1"
},
"whitenoise": {
"hashes": [
"sha256:0f5bfce6061ae6611cd9396a8231e088722e4fc67bc13a111be74c738d99375f",
"sha256:b2aeb45950597236f53b5342b3121c5de69c8da0109362aee506ce88e022d258"
],
"index": "pypi",
"markers": "python_version >= '3.9'",
"version": "==6.11.0"
}
},
"develop": {}

View File

@@ -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:

11
docker-entrypoint.sh Normal file
View File

@@ -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 "$@"

55
nginx.conf Normal file
View File

@@ -0,0 +1,55 @@
events {
worker_connections 1024;
multi_accept on;
use epoll;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
# Disable access logs for cleaner output
access_log off;
error_log /var/log/nginx/error.log warn;
server {
listen 80;
server_name _;
# Root is set to uploads directory
root /var/www/html/uploads;
index index.html index.htm;
# Strip /uploads/ prefix if present (from Caddy routing)
location ~ ^/uploads/(.*)$ {
rewrite ^/uploads/(.*)$ /$1 break;
try_files $uri =404;
}
# Direct access without /uploads/ prefix
location / {
try_files $uri $uri/ =404;
}
# Enable directory listing for uploads
autoindex on;
autoindex_exact_size off;
autoindex_localtime on;
# Security: deny access to database files
location ~ \.(sqlite3|db)$ {
deny all;
return 404;
}
# Set proper MIME types for common file types
location ~* \.(jpg|jpeg|png|gif|ico|svg|pdf|zip|tar|gz|mov|mp4|avi)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}
}

55
nginx/nginx.conf Normal file
View File

@@ -0,0 +1,55 @@
events {
worker_connections 1024;
multi_accept on;
use epoll;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
# Disable access logs for cleaner output
access_log off;
error_log /var/log/nginx/error.log warn;
server {
listen 80;
server_name _;
# Root is set to uploads directory
root /var/www/html/uploads;
index index.html index.htm;
# Strip /uploads/ prefix if present (from Caddy routing)
location ~ ^/uploads/(.*)$ {
rewrite ^/uploads/(.*)$ /$1 break;
try_files $uri =404;
}
# Direct access without /uploads/ prefix
location / {
try_files $uri $uri/ =404;
}
# Enable directory listing for uploads
autoindex on;
autoindex_exact_size off;
autoindex_localtime on;
# Security: deny access to database files
location ~ \.(sqlite3|db)$ {
deny all;
return 404;
}
# Set proper MIME types for common file types
location ~* \.(jpg|jpeg|png|gif|ico|svg|pdf|zip|tar|gz|mov|mp4|avi)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}
}

0
quotes/__init__.py Normal file
View File

43
quotes/admin.py Normal file
View File

@@ -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"]

6
quotes/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class QuotesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "quotes"

89
quotes/discord_utils.py Normal file
View File

@@ -0,0 +1,89 @@
"""
Discord webhook utilities for sending submission notifications.
"""
import os
import json
import requests
from django.conf import settings
def send_discord_webhook(submission):
"""
Sends a discord webhook notification if DISCORD_WEBHOOK is configured.
Joe wrote this!
"""
webhook_url = os.environ.get("DISCORD_WEBHOOK")
if not webhook_url:
return False
try:
files = submission.files.all()
file_urls = []
# Get file URLs
from .email_utils import get_file_urls_with_base_url
file_url_data = get_file_urls_with_base_url(files)
for item in file_url_data:
file_urls.append(
{
"name": item["file"].original_filename,
"url": item["url"],
}
)
# Build description with file links
description_parts = []
if submission.description:
description_parts.append(f"**Description:** {submission.description}")
if file_urls:
description_parts.append("\n**Uploaded Files:**")
for file_info in file_urls:
description_parts.append(f"- [{file_info['name']}]({file_info['url']})")
description = (
"\n".join(description_parts)
if description_parts
else "No description or files provided."
)
# Discord webhook payload
payload = {
"content": "Somebody submitted a quote!",
"embeds": [
{
"title": f"New Quote Request - Submission #{submission.id}",
"color": 3447003, # Blue color
"fields": [
{
"name": "Email",
"value": submission.email,
"inline": True,
},
{
"name": "Submitted At",
"value": submission.submitted_at.strftime(
"%Y-%m-%d %H:%M:%S"
),
"inline": True,
},
],
"description": description,
"footer": {
"text": f"Submission ID: #{submission.id}",
},
}
],
}
response = requests.post(webhook_url, json=payload, timeout=10)
response.raise_for_status()
return True
except Exception as e:
print(f"Error sending Discord webhook: {e}")
return False

256
quotes/email_utils.py Normal file
View File

@@ -0,0 +1,256 @@
"""
Email utilities for sending submission notifications.
"""
import signal
import threading
import logging
from django.core.mail import send_mail, EmailMultiAlternatives
from django.template.loader import render_to_string
from django.conf import settings
logger = logging.getLogger(__name__)
class TimeoutError(Exception):
"""Custom timeout exception"""
pass
def timeout_handler(signum, frame):
"""Signal handler for timeout"""
raise TimeoutError("Operation timed out")
def send_email_with_timeout(msg, timeout_seconds=5):
"""
Send email with a timeout to prevent hanging.
Uses signal-based timeout on Unix, threading fallback otherwise.
Args:
msg: EmailMultiAlternatives instance
timeout_seconds: Maximum time to wait for email to send
Returns:
bool: True if sent successfully, False otherwise
"""
result = [None] # Use list to allow modification in nested function
exception = [None]
def send_email():
try:
msg.send()
result[0] = True
except Exception as e:
exception[0] = e
result[0] = False
# Try signal-based timeout first (Unix/Linux)
try:
signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(timeout_seconds)
try:
msg.send()
signal.alarm(0) # Cancel the alarm
return True
except TimeoutError:
logger.error(f"Email sending timed out after {timeout_seconds} seconds")
signal.alarm(0) # Cancel the alarm
return False
except Exception as e:
signal.alarm(0) # Cancel the alarm
logger.error(f"Error sending email: {e}", exc_info=True)
return False
except (AttributeError, ValueError, OSError):
# Windows doesn't support SIGALRM, or signal already in use
# Use threading-based timeout as fallback
email_thread = threading.Thread(target=send_email)
email_thread.daemon = True
email_thread.start()
email_thread.join(timeout=timeout_seconds)
if email_thread.is_alive():
logger.error(
f"Email sending timed out after {timeout_seconds} seconds (threading)"
)
return False
if exception[0]:
logger.error(f"Error sending email: {exception[0]}", exc_info=True)
return False
return result[0] if result[0] is not None else False
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")
owner_sent = send_email_with_timeout(msg, timeout_seconds=30)
if not owner_sent:
logger.error(f"Failed to send owner email to {owner_email}")
except Exception as e:
logger.error(f"Error sending owner email: {e}", exc_info=True)
owner_sent = False
# 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")
submitter_sent = send_email_with_timeout(msg, timeout_seconds=30)
if not submitter_sent:
logger.error(f"Failed to send submitter email to {submission.email}")
except Exception as e:
logger.error(f"Error sending submitter email: {e}", exc_info=True)
submitter_sent = False
return (owner_sent, submitter_sent)

View File

@@ -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"],
},
),
]

View File

121
quotes/models.py Normal file
View File

@@ -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}"

View File

@@ -1,3 +1,4 @@
Django>=4.2,<5.0
whitenoise>=6.0,<7.0
requests>=2.31,<3.0

View File

@@ -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)

View File

@@ -0,0 +1,132 @@
import json
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
def send_progress_update(step, message, progress):
"""Helper to send a progress update as JSON"""
return json.dumps({"step": step, "message": message, "progress": progress}) + "\n"
@xframe_options_exempt
@csrf_exempt
def submit_quote(request):
"""
Handle quote submission with progress updates.
Steps:
1. Upload files
2. Save files
3. Send email
4. Verify email sent
"""
if request.method != "POST":
return JsonResponse({"error": "Method not allowed"}, status=405)
# Read form data first (files need to be read from request)
email = request.POST.get("email", "").strip()
notes = request.POST.get("notes", "").strip()
files = request.FILES.getlist("files")
def process_quote():
try:
# Step 1: Validate email
yield send_progress_update(1, "Validating email address...", 5)
if not email:
raise ValueError("Email is required")
try:
validate_email(email)
except ValidationError:
raise ValueError(f"Invalid email address: {email}")
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_<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():
f.write(chunk)
# 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 webook if config
from quotes.discord_utils import send_discord_webhook
yield send_progress_update(4, "Triggering hooks...", 60)
send_discord_webhook(submission)
# Step 5: Send emails
from quotes.email_utils import send_submission_emails
yield send_progress_update(4, "Sending emails...", 70)
owner_sent, submitter_sent = send_submission_emails(submission)
# 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
)
time.sleep(0.1)
# Complete
yield send_progress_update(
5,
"Quote request submitted successfully! Check your email for confirmation.",
100,
)
except Exception as e:
yield send_progress_update("error", f"An error occurred: {str(e)}", 0)
response = StreamingHttpResponse(process_quote(), content_type="text/event-stream")
response["Cache-Control"] = "no-cache"
response["X-Accel-Buffering"] = "no"
return response

View File

@@ -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", "")

View File

@@ -4,11 +4,13 @@ URL configuration for seduttomotorsports project.
from django.urls import path
from . import views
from . import quote_submit
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path("", views.quote_upload, name="quote_upload"),
path("submit-quote/", quote_submit.submit_quote, name="submit_quote"),
]
if settings.DEBUG:

View File

@@ -18,9 +18,10 @@ body {
.upload-box {
width: 100%;
min-height: 100vh;
min-height: auto;
background: white;
padding: 16px;
padding-bottom: 200px; /* Reserve space for progress bar and files */
display: flex;
flex-direction: column;
}
@@ -30,6 +31,47 @@ h2 {
color: #333;
}
.form-field {
margin-bottom: 20px;
}
.form-field label {
display: block;
margin-bottom: 8px;
color: #333;
font-weight: 500;
font-size: 14px;
}
.form-field input[type="email"],
.form-field textarea {
width: 100%;
padding: 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
font-family: Arial, sans-serif;
transition: border-color 0.3s;
}
.form-field input[type="email"]:focus,
.form-field textarea:focus {
outline: none;
border-color: #0066cc;
}
.form-field input[type="email"]:disabled,
.form-field textarea:disabled {
background: #f5f5f5;
cursor: not-allowed;
opacity: 0.7;
}
.form-field textarea {
resize: vertical;
min-height: 80px;
}
.drop-area {
border: 2px dashed #ccc;
border-radius: 4px;
@@ -49,6 +91,11 @@ h2 {
background: #e6f2ff;
}
.drop-area.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.drop-area p + p {
font-size: 14px;
color: #666;
@@ -96,14 +143,34 @@ button:disabled {
cursor: not-allowed;
}
.todo {
.progress-container {
margin-top: 20px;
padding: 10px;
background: #fff3cd;
border: 1px solid #ffc107;
padding: 16px;
background: #f9f9f9;
border-radius: 4px;
}
.progress-bar {
width: 100%;
height: 24px;
background: #e0e0e0;
border-radius: 12px;
overflow: hidden;
margin-bottom: 10px;
}
.progress-fill {
height: 100%;
background: #0066cc;
border-radius: 12px;
transition: width 0.3s ease;
width: 0%;
}
.progress-message {
font-size: 14px;
color: #856404;
color: #666;
text-align: center;
}
footer {

186
static/js/quote_upload.js Normal file
View File

@@ -0,0 +1,186 @@
const dropArea = document.getElementById('dropArea');
const fileInput = document.getElementById('fileInput');
const fileList = document.getElementById('fileList');
const submitBtn = document.getElementById('submitBtn');
const emailInput = document.getElementById('emailInput');
const notesInput = document.getElementById('notesInput');
let selectedFiles = [];
function checkCanSubmit() {
const hasEmail = emailInput.value.trim().length > 0;
const hasNotes = notesInput.value.trim().length > 0;
const hasFiles = selectedFiles.length > 0;
submitBtn.disabled = !(hasEmail || hasNotes || hasFiles);
}
emailInput.addEventListener('input', checkCanSubmit);
notesInput.addEventListener('input', checkCanSubmit);
dropArea.addEventListener('click', () => fileInput.click());
dropArea.addEventListener('dragover', (e) => {
e.preventDefault();
dropArea.classList.add('dragover');
});
dropArea.addEventListener('dragleave', () => {
dropArea.classList.remove('dragover');
});
dropArea.addEventListener('drop', (e) => {
e.preventDefault();
dropArea.classList.remove('dragover');
handleFiles(e.dataTransfer.files);
});
fileInput.addEventListener('change', (e) => {
handleFiles(e.target.files);
});
function handleFiles(files) {
selectedFiles = Array.from(files);
displayFiles();
checkCanSubmit();
}
function displayFiles() {
if (selectedFiles.length === 0) {
fileList.classList.remove('show');
return;
}
fileList.classList.add('show');
fileList.innerHTML = selectedFiles.map(file =>
`<div class="file-item">${file.name} (${(file.size / 1024).toFixed(1)} KB)</div>`
).join('');
}
submitBtn.addEventListener('click', async () => {
const email = emailInput.value.trim();
const notes = notesInput.value.trim();
// Validate at least one field
if (!email && !notes && selectedFiles.length === 0) {
return;
}
// Disable form during submission
submitBtn.disabled = true;
emailInput.disabled = true;
notesInput.disabled = true;
fileInput.disabled = true;
dropArea.style.pointerEvents = 'none';
dropArea.classList.add('disabled');
// Show progress bar
const progressContainer = document.getElementById('progressContainer');
const progressFill = document.getElementById('progressFill');
const progressMessage = document.getElementById('progressMessage');
progressContainer.style.display = 'block';
progressFill.style.width = '0%';
progressMessage.textContent = 'Starting...';
// Scroll to progress bar so it's visible (for Google Pages iframe compatibility)
setTimeout(() => {
progressContainer.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 100);
try {
// Create FormData
const formData = new FormData();
if (email) formData.append('email', email);
if (notes) formData.append('notes', notes);
selectedFiles.forEach(file => {
formData.append('files', file);
});
// Submit and read streaming response
const response = await fetch('/submit-quote/', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`Server error: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep incomplete line in buffer
for (const line of lines) {
if (line.trim()) {
try {
const update = JSON.parse(line);
progressFill.style.width = update.progress + '%';
progressMessage.textContent = update.message;
if (update.step === 5 || update.step === 'error') {
// Complete or error
if (update.step === 5) {
setTimeout(() => {
// Reset form after success
emailInput.value = '';
notesInput.value = '';
selectedFiles = [];
displayFiles();
checkCanSubmit();
// Re-enable form (but keep progress bar visible)
emailInput.disabled = false;
notesInput.disabled = false;
fileInput.disabled = false;
dropArea.style.pointerEvents = 'auto';
dropArea.classList.remove('disabled');
}, 2000);
} else {
// Error - re-enable form
emailInput.disabled = false;
notesInput.disabled = false;
fileInput.disabled = false;
dropArea.style.pointerEvents = 'auto';
dropArea.classList.remove('disabled');
submitBtn.disabled = false;
}
}
} catch (e) {
console.error('Error parsing progress update:', e);
}
}
}
}
} catch (error) {
progressMessage.textContent = `Error: ${error.message}`;
progressFill.style.width = '0%';
// Re-enable form
emailInput.disabled = false;
notesInput.disabled = false;
fileInput.disabled = false;
dropArea.style.pointerEvents = 'auto';
dropArea.classList.remove('disabled');
submitBtn.disabled = false;
}
});
// Attempt to auto-resize the iframe height (if parent allows)
function postHeight() {
const height = document.documentElement.scrollHeight;
try {
parent.postMessage({ type: 'sedutto-ifr-height', height }, '*');
} catch (e) {
// ignore if cross-origin restrictions apply
}
}
window.addEventListener('load', postHeight);
window.addEventListener('resize', postHeight);
const mo = new MutationObserver(postHeight);
mo.observe(document.body, { childList: true, subtree: true });

View File

@@ -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>

View File

@@ -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>

View File

@@ -11,87 +11,37 @@
<body>
<div class="upload-box">
<h2>Request a Quote</h2>
<div class="form-field">
<label for="emailInput">Email</label>
<input type="email" id="emailInput" placeholder="your.email@example.com">
</div>
<div class="form-field">
<label for="notesInput">Notes / Comments</label>
<textarea id="notesInput" rows="4" placeholder="Additional information, comments or questions!"></textarea>
</div>
<div class="drop-area" id="dropArea">
<p>Drag & drop files here</p>
<p>Drag & drop drawings, CAD models, images or anything else here</p>
<p>or click to browse</p>
<input type="file" id="fileInput" multiple>
</div>
<div class="file-list" id="fileList"></div>
<button id="submitBtn" disabled>Submit</button>
<div class="todo">
<strong>TODO:</strong> Implement file upload functionality
<div class="progress-container" id="progressContainer" style="display: none;">
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="progress-message" id="progressMessage"></div>
</div>
<footer>
Hosted by <a href="https://kitsunehosting.net" target="_blank"
rel="noopener noreferrer">kitsunehosting.net</a>
</footer>
</div>
<script>
const dropArea = document.getElementById('dropArea');
const fileInput = document.getElementById('fileInput');
const fileList = document.getElementById('fileList');
const submitBtn = document.getElementById('submitBtn');
let selectedFiles = [];
dropArea.addEventListener('click', () => fileInput.click());
dropArea.addEventListener('dragover', (e) => {
e.preventDefault();
dropArea.classList.add('dragover');
});
dropArea.addEventListener('dragleave', () => {
dropArea.classList.remove('dragover');
});
dropArea.addEventListener('drop', (e) => {
e.preventDefault();
dropArea.classList.remove('dragover');
handleFiles(e.dataTransfer.files);
});
fileInput.addEventListener('change', (e) => {
handleFiles(e.target.files);
});
function handleFiles(files) {
selectedFiles = Array.from(files);
displayFiles();
submitBtn.disabled = selectedFiles.length === 0;
}
function displayFiles() {
if (selectedFiles.length === 0) {
fileList.classList.remove('show');
return;
}
fileList.classList.add('show');
fileList.innerHTML = selectedFiles.map(file =>
`<div class="file-item">${file.name} (${(file.size / 1024).toFixed(1)} KB)</div>`
).join('');
}
submitBtn.addEventListener('click', () => {
// TODO: Submit files to backend
alert('Upload functionality - TODO');
});
// Attempt to auto-resize the iframe height (if parent allows)
function postHeight() {
const height = document.documentElement.scrollHeight;
try {
parent.postMessage({ type: 'sedutto-ifr-height', height }, '*');
} catch (e) {
// ignore if cross-origin restrictions apply
}
}
window.addEventListener('load', postHeight);
window.addEventListener('resize', postHeight);
const mo = new MutationObserver(postHeight);
mo.observe(document.body, { childList: true, subtree: true });
</script>
<script src="{% static 'js/quote_upload.js' %}"></script>
</body>
</html>