Compare commits
9 Commits
20b5086800
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e4022e1d5 | ||
|
|
d9208e277c | ||
|
|
53c6fad262 | ||
|
|
c97b06e07b | ||
|
|
2836d5954e | ||
|
|
0183b03bc2 | ||
|
|
4cdd902b2e | ||
|
|
c12aedeb20 | ||
|
|
28336aa268 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -36,6 +36,7 @@ db.sqlite3
|
||||
db.sqlite3-journal
|
||||
/media
|
||||
/staticfiles
|
||||
/local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
|
||||
17
Dockerfile
17
Dockerfile
@@ -2,10 +2,21 @@ FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . /app
|
||||
|
||||
RUN pip install --upgrade pip
|
||||
RUN pip install --upgrade gunicorn
|
||||
|
||||
COPY requirements.txt /app
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
RUN pip install --upgrade gunicorn
|
||||
|
||||
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"]
|
||||
|
||||
2
Pipfile
2
Pipfile
@@ -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
181
Pipfile.lock
generated
@@ -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": {}
|
||||
|
||||
21
README.md
21
README.md
@@ -8,17 +8,34 @@ simple-ish!
|
||||
|
||||
uses sqlite3 stored alongside the customer files.
|
||||
|
||||
## Development
|
||||
|
||||
```shell
|
||||
pipenv run python manage.py runserver
|
||||
```
|
||||
|
||||
|
||||
## Compose Example
|
||||
|
||||
```yaml
|
||||
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:
|
||||
- SECRET_KEY=your-very-secret-production-key
|
||||
- ALLOWED_HOSTS=yourdomain.com,localhost,127.0.0.1
|
||||
restart: always
|
||||
```
|
||||
```
|
||||
|
||||
## Building and pushing notes
|
||||
|
||||
```
|
||||
docker build -t git.kitsunehosting.net/kenwood/smw-upload:latest . --load
|
||||
docker push git.kitsunehosting.net/kenwood/smw-upload:latest
|
||||
```
|
||||
|
||||
(obviously you have to be me to do this)
|
||||
|
||||
11
docker-entrypoint.sh
Normal file
11
docker-entrypoint.sh
Normal 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
55
nginx.conf
Normal 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
55
nginx/nginx.conf
Normal 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
0
quotes/__init__.py
Normal file
43
quotes/admin.py
Normal file
43
quotes/admin.py
Normal 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
6
quotes/apps.py
Normal 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
89
quotes/discord_utils.py
Normal 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
256
quotes/email_utils.py
Normal 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)
|
||||
112
quotes/migrations/0001_initial.py
Normal file
112
quotes/migrations/0001_initial.py
Normal 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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
quotes/migrations/__init__.py
Normal file
0
quotes/migrations/__init__.py
Normal file
121
quotes/models.py
Normal file
121
quotes/models.py
Normal 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}"
|
||||
@@ -1,2 +1,4 @@
|
||||
Django>=4.2,<5.0
|
||||
whitenoise>=6.0,<7.0
|
||||
requests>=2.31,<3.0
|
||||
|
||||
|
||||
52
seduttomachineworks_project/email_config.py
Normal file
52
seduttomachineworks_project/email_config.py
Normal 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)
|
||||
132
seduttomachineworks_project/quote_submit.py
Normal file
132
seduttomachineworks_project/quote_submit.py
Normal 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
|
||||
@@ -16,7 +16,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY", "django-insecure-change-this-in-production")
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = os.environ.get("DEBUG", "True") == "True"
|
||||
DEBUG = os.environ.get("DEBUG", "False") == "True"
|
||||
|
||||
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(",")
|
||||
|
||||
@@ -28,10 +28,12 @@ INSTALLED_APPS = [
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"quotes",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
@@ -62,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
|
||||
@@ -75,7 +90,7 @@ DATABASES = {
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
||||
TIME_ZONE = "UTC"
|
||||
TIME_ZONE = "America/New_York"
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
@@ -87,9 +102,23 @@ USE_TZ = True
|
||||
|
||||
STATIC_URL = "static/"
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
STATICFILES_DIRS = [BASE_DIR / "static"]
|
||||
|
||||
# WhiteNoise configuration for serving static files
|
||||
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
|
||||
@@ -98,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", "")
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
"""
|
||||
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("", views.quote_upload, name="quote_upload"),
|
||||
path("submit-quote/", quote_submit.submit_quote, name="submit_quote"),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += [
|
||||
path("test-iframe/", views.test_iframe, name="test_iframe"),
|
||||
]
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from django.shortcuts import render
|
||||
from django.views.decorators.clickjacking import xframe_options_exempt
|
||||
from django.http import Http404
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@xframe_options_exempt
|
||||
@@ -8,4 +10,10 @@ def quote_upload(request):
|
||||
Simple embeddable quote upload box.
|
||||
TODO: Implement file upload and email integration
|
||||
"""
|
||||
return render(request, 'quote_upload.html')
|
||||
return render(request, "quote_upload.html")
|
||||
|
||||
|
||||
def test_iframe(request):
|
||||
if not settings.DEBUG:
|
||||
raise Http404("Not available in production")
|
||||
return render(request, "test_iframe.html")
|
||||
|
||||
191
static/css/quote_upload.css
Normal file
191
static/css/quote_upload.css
Normal file
@@ -0,0 +1,191 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.upload-box {
|
||||
width: 100%;
|
||||
min-height: auto;
|
||||
background: white;
|
||||
padding: 16px;
|
||||
padding-bottom: 200px; /* Reserve space for progress bar and files */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 20px;
|
||||
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;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.drop-area:hover {
|
||||
border-color: #666;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.drop-area.dragover {
|
||||
border-color: #0066cc;
|
||||
background: #e6f2ff;
|
||||
}
|
||||
|
||||
.drop-area.disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.drop-area p + p {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
margin-top: 20px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-list.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
padding: 10px;
|
||||
background: #f9f9f9;
|
||||
margin-bottom: 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 20px;
|
||||
padding: 12px 24px;
|
||||
background: #0066cc;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #0052a3;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
margin-top: 20px;
|
||||
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: #666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: auto;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: #666;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
122
static/css/test_iframe.css
Normal file
122
static/css/test_iframe.css
Normal file
@@ -0,0 +1,122 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.test-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.test-info {
|
||||
background: #e3f2fd;
|
||||
border-left: 4px solid #2196f3;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.test-info p {
|
||||
margin: 5px 0;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.iframe-wrapper {
|
||||
background: white;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.iframe-wrapper h2 {
|
||||
margin-bottom: 10px;
|
||||
color: #555;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.iframe-container {
|
||||
width: 100%;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
}
|
||||
|
||||
iframe {
|
||||
width: 100%;
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.size-controls {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.size-controls label {
|
||||
margin-right: 15px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.size-controls input[type="number"] {
|
||||
width: 80px;
|
||||
padding: 5px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.size-presets {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.size-presets button {
|
||||
padding: 5px 15px;
|
||||
margin-right: 10px;
|
||||
background: #2196f3;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.size-presets button:hover {
|
||||
background: #1976d2;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile {
|
||||
max-width: 375px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tablet {
|
||||
max-width: 768px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.desktop {
|
||||
max-width: 1024px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
186
static/js/quote_upload.js
Normal file
186
static/js/quote_upload.js
Normal 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 });
|
||||
|
||||
117
templates/emails/submission_confirmation.html
Normal file
117
templates/emails/submission_confirmation.html
Normal 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>
|
||||
115
templates/emails/submission_notification_owner.html
Normal file
115
templates/emails/submission_notification_owner.html
Normal 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>
|
||||
@@ -1,3 +1,4 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
@@ -5,210 +6,42 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Request a Quote</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.upload-box {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
background: white;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.drop-area {
|
||||
border: 2px dashed #ccc;
|
||||
border-radius: 4px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.drop-area:hover {
|
||||
border-color: #666;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.drop-area.dragover {
|
||||
border-color: #0066cc;
|
||||
background: #e6f2ff;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
margin-top: 20px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-list.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
padding: 10px;
|
||||
background: #f9f9f9;
|
||||
margin-bottom: 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 20px;
|
||||
padding: 12px 24px;
|
||||
background: #0066cc;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #0052a3;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.todo {
|
||||
margin-top: 20px;
|
||||
padding: 10px;
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: auto;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: #666;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="{% static 'css/quote_upload.css' %}">
|
||||
</head>
|
||||
|
||||
<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 style="font-size: 14px; color: #666; margin-top: 10px;">or click to browse</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>
|
||||
103
templates/test_iframe.html
Normal file
103
templates/test_iframe.html
Normal file
@@ -0,0 +1,103 @@
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Iframe Test - Quote Upload</title>
|
||||
<link rel="stylesheet" href="{% static 'css/test_iframe.css' %}">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="test-container">
|
||||
<h1>Iframe Embedding Test</h1>
|
||||
<div class="test-info">
|
||||
<p><strong>Purpose:</strong> Test how the quote upload page appears when embedded in an iframe</p>
|
||||
<p><strong>URL:</strong> <span id="iframeUrl"></span></p>
|
||||
</div>
|
||||
|
||||
<div class="iframe-wrapper">
|
||||
<h2>Responsive Iframe (Auto Height)</h2>
|
||||
<div class="iframe-container" id="iframeContainer">
|
||||
<iframe id="testIframe" src="/" title="Quote Upload Test"></iframe>
|
||||
</div>
|
||||
<div class="size-controls">
|
||||
<label>Width: <input type="number" id="widthInput" value="100" min="300" max="2000"> px</label>
|
||||
<label>Height: <input type="number" id="heightInput" value="800" min="400" max="3000"> px</label>
|
||||
<div class="size-presets">
|
||||
<button onclick="setSize('mobile')">Mobile (375px)</button>
|
||||
<button onclick="setSize('tablet')">Tablet (768px)</button>
|
||||
<button onclick="setSize('desktop')">Desktop (1024px)</button>
|
||||
<button onclick="setSize('full')">Full Width</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Display the iframe URL
|
||||
document.getElementById('iframeUrl').textContent = window.location.origin + '/';
|
||||
|
||||
const iframe = document.getElementById('testIframe');
|
||||
const iframeContainer = document.getElementById('iframeContainer');
|
||||
const widthInput = document.getElementById('widthInput');
|
||||
const heightInput = document.getElementById('heightInput');
|
||||
|
||||
// Listen for height messages from the iframe
|
||||
window.addEventListener('message', function (event) {
|
||||
if (event.data && event.data.type === 'sedutto-ifr-height') {
|
||||
iframe.style.height = event.data.height + 'px';
|
||||
heightInput.value = event.data.height;
|
||||
}
|
||||
});
|
||||
|
||||
// Update iframe size when inputs change
|
||||
widthInput.addEventListener('input', function () {
|
||||
iframeContainer.style.width = this.value + 'px';
|
||||
});
|
||||
|
||||
heightInput.addEventListener('input', function () {
|
||||
iframe.style.height = this.value + 'px';
|
||||
});
|
||||
|
||||
// Size presets
|
||||
function setSize(preset) {
|
||||
const sizes = {
|
||||
mobile: 375,
|
||||
tablet: 768,
|
||||
desktop: 1024,
|
||||
full: '100%'
|
||||
};
|
||||
|
||||
if (preset === 'full') {
|
||||
iframeContainer.style.width = '100%';
|
||||
iframeContainer.classList.remove('mobile', 'tablet', 'desktop');
|
||||
widthInput.value = window.innerWidth - 60; // Account for padding
|
||||
} else {
|
||||
const width = sizes[preset];
|
||||
iframeContainer.style.width = width + 'px';
|
||||
iframeContainer.className = 'iframe-container ' + preset;
|
||||
widthInput.value = width;
|
||||
}
|
||||
}
|
||||
|
||||
// Initial auto-height setup
|
||||
iframe.addEventListener('load', function () {
|
||||
// Try to get initial height
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
|
||||
const height = iframeDoc.documentElement.scrollHeight;
|
||||
iframe.style.height = height + 'px';
|
||||
heightInput.value = height;
|
||||
} catch (e) {
|
||||
// Cross-origin restrictions - rely on postMessage
|
||||
console.log('Cannot access iframe content directly, using postMessage');
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user