diff --git a/artel/Dockerfile b/artel/Dockerfile index 39fb199..984a8ee 100644 --- a/artel/Dockerfile +++ b/artel/Dockerfile @@ -46,6 +46,8 @@ COPY --chown=wagtail:wagtail . . # Use user "wagtail" to run the build commands below and the server itself. USER wagtail +RUN mkdir -p /app/media + # Collect static files. RUN python manage.py collectstatic --noinput --clear diff --git a/artel/artel/settings/base.py b/artel/artel/settings/base.py index a5727b6..49da1a4 100644 --- a/artel/artel/settings/base.py +++ b/artel/artel/settings/base.py @@ -18,6 +18,19 @@ import json PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(PROJECT_DIR) +import sentry_sdk +from sentry_sdk.integrations.django import DjangoIntegration + +# -> GlitchTip error reporting +sentry_sdk.init( + dsn=os.environ.get("SENTRY_DSN", ''), + integrations=[DjangoIntegration()], + auto_session_tracking=False, + traces_sample_rate=0 +) + +SENTRY_ENVIRONMENT = os.environ.get("SENTRY_ENVIRONMENT", '') + # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ @@ -27,6 +40,8 @@ BASE_DIR = os.path.dirname(PROJECT_DIR) INSTALLED_APPS = [ "home", + "store", + "mailings", "blog", "search", "setup", @@ -193,6 +208,19 @@ WAGTAILSEARCH_BACKENDS = { # e.g. in notification emails. Don't include '/admin' or a trailing slash WAGTAILADMIN_BASE_URL = "https://artel.tepewu.pl" +# Messages +from django.contrib import messages + +MESSAGE_STORAGE = "django.contrib.messages.storage.session.SessionStorage" +MESSAGE_TAGS = { + messages.DEBUG: "debug", + messages.INFO: "info", + messages.SUCCESS: "success", + messages.WARNING: "warning", + messages.ERROR: "danger", +} + + # STORE SETTINGS PRODUCTS_PER_PAGE = 6 @@ -205,4 +233,22 @@ EMAIL_HOST = os.environ.get('EMAIL_HOST', 'smtp.gmail.com') EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', '') EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", '') EMAIL_PORT = os.environ.get('EMAIL_PORT', 587) -DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'mtyton@tepewu.pl') +EMAIL_USE_TLS = True +DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'artel-sklep@tepewu.pl') + + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "root": { + "handlers": ["console"], + "level": "WARNING", + }, +} + +PRODUCTS_CSV_PATH = os.environ.get("PRODUCTS_CSV_PATH", "products.csv") diff --git a/artel/artel/settings/dev.py b/artel/artel/settings/dev.py index 410b60f..380a4cc 100644 --- a/artel/artel/settings/dev.py +++ b/artel/artel/settings/dev.py @@ -3,13 +3,20 @@ from .base import * # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True + # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = "django-insecure-s7hlfa-#n7-v0#&-0ko3(efe+@^d@ie1_1-633e&jb1rh$)j1p" # SECURITY WARNING: define the correct hosts in production! ALLOWED_HOSTS = ["*"] -EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = "smtp-server" +EMAIL_HOST_USER = None +EMAIL_HOST_PASSWORD = None +EMAIL_PORT = 1025 +EMAIL_USE_TLS = False +DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'mtyton@tepewu.pl') try: diff --git a/artel/artel/settings/production.py b/artel/artel/settings/production.py index f885e60..0faaaa5 100644 --- a/artel/artel/settings/production.py +++ b/artel/artel/settings/production.py @@ -7,10 +7,12 @@ ALLOWED_HOSTS = [ "localhost", "0.0.0.0", "127.0.0.1", + "artel.citizen4.eu", "artel.tepewu.pl" ] CSRF_TRUSTED_ORIGINS = [ "https://0.0.0.0", "http://0.0.0.0", "https://localhost", "http://localhost", - "https://artel.tepewu.pl" + "https://artel.tepewu.pl", + "https://artel.citizen4.eu", ] diff --git a/artel/artel/static/js/artel.js b/artel/artel/static/js/artel.js index e69de29..ffe2a1f 100644 --- a/artel/artel/static/js/artel.js +++ b/artel/artel/static/js/artel.js @@ -0,0 +1,5 @@ + +// close all alerts after 2 seconds +$('.alert').fadeTo(2000, 500).slideUp(500, function(){ + $("#success-alert").slideUp(500); +}); diff --git a/artel/artel/templates/base.html b/artel/artel/templates/base.html index 73922ea..8045f61 100644 --- a/artel/artel/templates/base.html +++ b/artel/artel/templates/base.html @@ -49,7 +49,12 @@ {% endif %}
- {% block content %}{% endblock %} + {% for message in messages %} + + {% endfor %} + {% block content %}{% endblock %}
{% if navbar_position == 'right' %}
@@ -60,10 +65,10 @@
{# Global javascript #} - - + + {% block extra_js %} {# Override this in templates to add extra javascript #} {% endblock %} diff --git a/artel/artel/urls.py b/artel/artel/urls.py index 36836d5..2652515 100644 --- a/artel/artel/urls.py +++ b/artel/artel/urls.py @@ -14,6 +14,9 @@ from search import views as search_views from setup.views import SetupPageView as setup_view + +handler404 = 'artel.views.my_custom_page_not_found_view' + urlpatterns = [ path("django-admin/", admin.site.urls), path("admin/", include(wagtailadmin_urls)), diff --git a/artel/artel/views.py b/artel/artel/views.py new file mode 100644 index 0000000..7a3135b --- /dev/null +++ b/artel/artel/views.py @@ -0,0 +1,11 @@ +from django.conf import settings +from django.http import HttpResponseNotFound +from sentry_sdk import capture_message + + +def my_custom_page_not_found_view(*args, **kwargs): + if settings.SENTRY_ENVIRONMENT != 'production': + capture_message("Page not found!", level="error") + + # return any response here, e.g.: + return HttpResponseNotFound("Not found") diff --git a/artel/docgenerator/admin.py b/artel/docgenerator/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/artel/docgenerator/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/artel/docgenerator/generators.py b/artel/docgenerator/generators.py deleted file mode 100644 index 7828e6b..0000000 --- a/artel/docgenerator/generators.py +++ /dev/null @@ -1,44 +0,0 @@ -from abc import ( - ABC, - abstractmethod -) -from typing import ( - Dict, - Any -) - -from django.db.models import Model -from docxtpl import DocxTemplate - - -class DocumentGeneratorInterface(ABC): - @abstractmethod - def load_template(self, path: str): - ... - - @abstractmethod - def get_extra_context(self) -> Dict[str, Any]: - ... - - @abstractmethod - def generate_file(self, context: Dict[str, Any] = None): - ... - - -class BaseDocumentGenerator(DocumentGeneratorInterface): - - def __init__(self, instance: Model) -> None: - super().__init__() - self.instance = instance - - def load_template(self, path: str): - return DocxTemplate(path) - - def get_extra_context(self): - return {} - - -class PdfFromDocGenerator(BaseDocumentGenerator): - def generate_file(self, context: Dict[str, Any] = None): - template = self.load_template() - context.update(self.get_extra_context()) diff --git a/artel/docgenerator/models.py b/artel/docgenerator/models.py deleted file mode 100644 index f50168a..0000000 --- a/artel/docgenerator/models.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.db import models -from django.core.files.storage import Storage - - -class DocumentTemplate(models.Model): - name = models.CharField(max_length=255) - file = models.FileField(upload_to="doc_templates/", ) - - def __str__(self) -> str: - return self.name diff --git a/artel/docgenerator/tests.py b/artel/docgenerator/tests.py deleted file mode 100644 index 7b37ab0..0000000 --- a/artel/docgenerator/tests.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.test import TestCase - - -class PdfFromDocGeneratorTestCase(TestCase): - ... \ No newline at end of file diff --git a/artel/docker-compose-prod.yml b/artel/docker-compose-prod.yml index 23a0c08..91ed14b 100644 --- a/artel/docker-compose-prod.yml +++ b/artel/docker-compose-prod.yml @@ -20,9 +20,10 @@ services: build: dockerfile: Dockerfile context: ./ + user: "${UID}:${GID}" restart: always ports: - - "8001:8000" + - "8000:8000" volumes: - ./:/app environment: @@ -38,6 +39,7 @@ services: web: image: nginx + restart: always volumes: - ../nginx/conf.d/:/etc/nginx/conf.d/ - ./static/:/opt/services/comfy/static diff --git a/artel/docker-compose-stack.yml b/artel/docker-compose-stack.yml new file mode 100644 index 0000000..622d984 --- /dev/null +++ b/artel/docker-compose-stack.yml @@ -0,0 +1,93 @@ +version: "3.8" +services: + + db: + image: postgres + restart: always + environment: + - POSTGRES_ROOT_PASSWORD=${POSTGRES_ROOT_PASSWORD} + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=${POSTGRES_DB} + volumes: + - db:/var/lib/postgresql/data + networks: + - internal + deploy: + resources: + limits: + cpus: '1.0' + memory: 512M + + comfy: + image: comfy + user: "${UID}:${GID}" + restart: always + ports: + - "8000" + volumes: + - media:/app/media + - static:/app/static + environment: + - SECRET_KEY=${SECRET_KEY} + - DATABASE_URL=${DATABASE_URL} + - DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE} + - SENTRY_DSN=${SENTRY_DSN} + - SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT} + - EMAIL_HOST=${EMAIL_HOST} + - EMAIL_HOST_USER=${EMAIL_HOST_USER} + - EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD} + - EMAIL_PORT=${EMAIL_PORT} + - EMAIL_USE_TLS=${EMAIL_USE_TLS} + - DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL} + depends_on: + - db + networks: + - internal + deploy: + resources: + limits: + cpus: '1.0' + memory: 512M + + web: + image: nginx + restart: always + volumes: + - nginx:/etc/nginx/conf.d + - static:/opt/services/comfy/static + - media:/opt/services/comfy/media + ports: + - "80" + environment: + - NGINX_HOST=${NGINX_HOST} + - NGINX_PORT=${NGINX_PORT} + networks: + - internal + - web + deploy: + resources: + limits: + cpus: '1.0' + memory: 256M + labels: + - "traefik.enable=true" + - "traefik.docker.network=web" + - "traefik.http.routers.artel.rule=Host(`${NGINX_HOST}`)" + - "traefik.http.routers.artel.entrypoints=websecure" + - "traefik.http.services.artel.loadbalancer.server.port=80" + - "traefik.http.routers.artel.tls=true" + - "traefik.http.routers.artel.tls.certresolver=ovh" + +volumes: + db: + media: + static: + nginx: + +networks: + internal: + web: + external: + name: web + diff --git a/artel/docker-compose-test.yml b/artel/docker-compose-test.yml new file mode 100644 index 0000000..f44132b --- /dev/null +++ b/artel/docker-compose-test.yml @@ -0,0 +1,30 @@ +version: "3.8" +services: + test_db: + image: postgres + restart: always + environment: + - POSTGRES_ROOT_PASSWORD + - POSTGRES_USER + - POSTGRES_PASSWORD + - POSTGRES_DB + volumes: + - ../postgres/:/var/lib/postgresql + env_file: + - .env + test_comfy: + depends_on: + - test_db + build: + dockerfile: Dockerfile.local + context: ./ + user: "${UID}:${GID}" + volumes: + - ./:/app + environment: + - SECRET_KEY + - DATABASE_URL + env_file: + - .env + command: + python manage.py test --noinput diff --git a/artel/docker-compose.yml b/artel/docker-compose.yml index 8f4da6c..2a7f970 100644 --- a/artel/docker-compose.yml +++ b/artel/docker-compose.yml @@ -1,13 +1,24 @@ version: "3.8" services: + smtp-server: + image: mailhog/mailhog + ports: + - "1025:1025" + - "8025:8025" comfy: + restart: always + depends_on: + - smtp-server + - db build: dockerfile: Dockerfile.local context: ./ + user: "${UID}:${GID}" ports: - - "8000:8000" + - "8001:8000" volumes: - ./:/app + - media:/app/media environment: - SECRET_KEY - DATABASE_URL @@ -24,6 +35,17 @@ services: - POSTGRES_PASSWORD - POSTGRES_DB volumes: - - ../postgres/:/var/lib/postgresql + - db:/var/lib/postgresql/data env_file: - .env + + adminer: + image: adminer + restart: always + ports: + - "8002:8080" + + +volumes: + media: + db: diff --git a/artel/home/__pycache__/__init__.cpython-311.pyc b/artel/home/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 08bc455..0000000 Binary files a/artel/home/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/artel/home/__pycache__/models.cpython-311.pyc b/artel/home/__pycache__/models.cpython-311.pyc deleted file mode 100644 index 9a58c37..0000000 Binary files a/artel/home/__pycache__/models.cpython-311.pyc and /dev/null differ diff --git a/artel/home/migrations/__pycache__/0001_initial.cpython-311.pyc b/artel/home/migrations/__pycache__/0001_initial.cpython-311.pyc deleted file mode 100644 index 1d4c6e2..0000000 Binary files a/artel/home/migrations/__pycache__/0001_initial.cpython-311.pyc and /dev/null differ diff --git a/artel/home/migrations/__pycache__/0002_create_homepage.cpython-311.pyc b/artel/home/migrations/__pycache__/0002_create_homepage.cpython-311.pyc deleted file mode 100644 index e4582e1..0000000 Binary files a/artel/home/migrations/__pycache__/0002_create_homepage.cpython-311.pyc and /dev/null differ diff --git a/artel/home/migrations/__pycache__/0003_homepage_body.cpython-311.pyc b/artel/home/migrations/__pycache__/0003_homepage_body.cpython-311.pyc deleted file mode 100644 index d1c00e5..0000000 Binary files a/artel/home/migrations/__pycache__/0003_homepage_body.cpython-311.pyc and /dev/null differ diff --git a/artel/home/migrations/__pycache__/__init__.cpython-311.pyc b/artel/home/migrations/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 5d14f52..0000000 Binary files a/artel/home/migrations/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/artel/docgenerator/__init__.py b/artel/mailings/__init__.py similarity index 100% rename from artel/docgenerator/__init__.py rename to artel/mailings/__init__.py diff --git a/artel/mailings/admin.py b/artel/mailings/admin.py new file mode 100644 index 0000000..3600a42 --- /dev/null +++ b/artel/mailings/admin.py @@ -0,0 +1,73 @@ +from django.forms import fields + +from wagtail.contrib.modeladmin.options import ( + ModelAdmin, + ModelAdminGroup, + modeladmin_register +) + +from mailings import models + + +class MailTemplateAdmin(ModelAdmin): + model = models.MailTemplate + menu_label = "Mail templates" + menu_icon = 'mail' + menu_order = 100 + add_to_settings_menu = False + exclude_from_explorer = False + list_display = ( + "template_name", + ) + search_fields = ( + "template_name", + ) + list_filter = ( + "template_name", + ) + form_fields = ( + "template_name", + "template", + ) + + +class OutgoingMailAdmin(ModelAdmin): + model = models.OutgoingEmail + menu_label = "Outgoing mails" + menu_icon = 'mail' + menu_order = 100 + add_to_settings_menu = False + exclude_from_explorer = False + list_display = ( + "subject", + "sent", + ) + search_fields = ( + "subject", + ) + list_filter = ( + "subject", + "sender", + "recipient", + "template__template_name", + "sent", + ) + readonly_fields = ( + "subject", + "sender", + "recipient", + "sent" + ) + + +class MailingGroup(ModelAdminGroup): + menu_label = "Mailings" + menu_icon = 'mail' + menu_order = 200 + items = ( + MailTemplateAdmin, + OutgoingMailAdmin + ) + + +modeladmin_register(MailingGroup) diff --git a/artel/docgenerator/apps.py b/artel/mailings/apps.py similarity index 59% rename from artel/docgenerator/apps.py rename to artel/mailings/apps.py index 7c5f6d2..c2ec5b6 100644 --- a/artel/docgenerator/apps.py +++ b/artel/mailings/apps.py @@ -1,6 +1,6 @@ from django.apps import AppConfig -class DocgeneratorConfig(AppConfig): +class MailingsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'docgenerator' + name = 'mailings' diff --git a/artel/mailings/migrations/0001_initial.py b/artel/mailings/migrations/0001_initial.py new file mode 100644 index 0000000..a3de859 --- /dev/null +++ b/artel/mailings/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 4.1.9 on 2023-06-22 14:09 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="MailTemplate", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("template_name", models.CharField(max_length=255, unique=True)), + ("template", models.FileField(upload_to="mail_templates")), + ], + ), + migrations.CreateModel( + name="OutgoingEmail", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("subject", models.CharField(max_length=255)), + ("sender", models.EmailField(max_length=254)), + ("recipient", models.EmailField(max_length=254)), + ("sent", models.BooleanField(default=False)), + ( + "template", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="mailings.mailtemplate"), + ), + ], + ), + ] diff --git a/artel/docgenerator/migrations/__init__.py b/artel/mailings/migrations/__init__.py similarity index 100% rename from artel/docgenerator/migrations/__init__.py rename to artel/mailings/migrations/__init__.py diff --git a/artel/mailings/models.py b/artel/mailings/models.py new file mode 100644 index 0000000..0e63bef --- /dev/null +++ b/artel/mailings/models.py @@ -0,0 +1,111 @@ +import logging + +from typing import Any +from dataclasses import dataclass + +from django.db import models +from django.db import transaction +from django.template import ( + Template, + Context +) +from django.core.mail import EmailMessage +from django.conf import settings + + +logger = logging.getLogger(__name__) + + +@dataclass +class Attachment: + name: str + content: Any + contenttype: str + + +def send_mail( + to: list[str], + attachments: list[Attachment], + subject: str, + content: str, + sender_email: str = settings.DEFAULT_FROM_EMAIL + ): + message = EmailMessage( + subject=subject, + body=content, + from_email=sender_email, + to=to + ) + message.content_subtype = 'html' + for attachment in attachments: + message.attach(attachment.name, attachment.content, attachment.contenttype) + + sent = bool(message.send()) + if not sent: + logger.exception(f"Sending email to {to} with subject {subject} caused an exception") + return sent + + +class MailTemplate(models.Model): + template_name = models.CharField(max_length=255, unique=True) + + template = models.FileField( + upload_to="mail_templates" + ) + + def delete(self, *args, **kwargs): + # delete file + super().delete(*args, **kwargs) + + @transaction.on_commit + def remove_template_file(self): + self.template.delete() + + def load_and_process_template(self, context: dict|Context): + if not self.template: + logger.exception( + f"Template file is missing for template with "+ + f"pk={self.pk}, template_name={self.template_name}" + ) + raise FileNotFoundError("Template file is missing") + if isinstance(context, dict): + context = Context(context) + with open(self.template.path, "r", encoding="utf-8") as f: + content = f.read() + template = Template(content) + return template.render(context) + + +class OutgoingEmailManager(models.Manager): + + def send( + self, template_name: str, subject: str, + recipient: str, context: dict | Context, + sender:str, attachments: list[Attachment] = None + ): + template = MailTemplate.objects.get(template_name=template_name) + outgoing_email = self.create( + template=template, recipient=recipient, subject=subject, + sender=sender + ) + attachments = attachments or [] + # send email + sent = send_mail( + to=[recipient], sender_email=sender, + subject=subject, content=template.load_and_process_template(context), + attachments=attachments + ) + outgoing_email.sent = sent + outgoing_email.save() + return outgoing_email + + +class OutgoingEmail(models.Model): + subject = models.CharField(max_length=255) + template = models.ForeignKey(MailTemplate, on_delete=models.CASCADE) + sender = models.EmailField() + recipient = models.EmailField() + + sent = models.BooleanField(default=False) + + objects = OutgoingEmailManager() diff --git a/artel/mailings/tests/__init__.py b/artel/mailings/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/artel/mailings/tests/factories.py b/artel/mailings/tests/factories.py new file mode 100644 index 0000000..14212c9 --- /dev/null +++ b/artel/mailings/tests/factories.py @@ -0,0 +1,10 @@ +from factory.django import DjangoModelFactory +from factory import Faker + + +class MailTemplateFactory(DjangoModelFactory): + class Meta: + model = "mailings.MailTemplate" + + template_name = Faker("name") + template = Faker("file_name", extension="html") diff --git a/artel/mailings/tests/test_models.py b/artel/mailings/tests/test_models.py new file mode 100644 index 0000000..3d98f03 --- /dev/null +++ b/artel/mailings/tests/test_models.py @@ -0,0 +1,66 @@ +from django.test import TestCase +from django.core.files.uploadedfile import SimpleUploadedFile +from django.core import mail + +from mailings.models import ( + MailTemplate, + OutgoingEmail, +) + + +class TestMailTemplate(TestCase): + + def setUp(self) -> None: + super().setUp() + self.mail_template = MailTemplate.objects.create( + template_name="test_template", + template=SimpleUploadedFile( + "test_template.html", b"{{test_var}}" + ) + ) + + def test_load_and_process_template_success(self): + content = self.mail_template.load_and_process_template({"test_var": "test"}) + self.assertEqual(content, "test") + + def test_load_and_process_template_missing_var_failure(self): + content = self.mail_template.load_and_process_template({}) + self.assertEqual(content, "") + + def test_load_and_preprocess_template_no_template_file(self): + self.mail_template.template.delete() + self.mail_template.template = None + self.mail_template.save() + with self.assertRaises(FileNotFoundError): + self.mail_template.load_and_process_template({}) + + +class TestOutgoingEmail(TestCase): + + def setUp(self) -> None: + super().setUp() + self.mail_template = MailTemplate.objects.create( + template_name="test_template", + template=SimpleUploadedFile( + "test_template.html", b"{{test_var}}" + ) + ) + + def test_send_success(self): + email = OutgoingEmail.objects.send( + template_name="test_template", + recipient="test@stardust.io", context={}, + sender="sklep-test@stardust.io", + subject="Test subject" + ) + self.assertEqual(email.sent, True) + self.assertEqual(mail.outbox[0].subject, "Test subject") + + def test_send_missing_template_failure(self): + with self.assertRaises(MailTemplate.DoesNotExist): + OutgoingEmail.objects.send( + template_name="missing_template", + recipient="", sender="", context={}, + subject="Test subject" + ) + self.assertEqual(len(mail.outbox), 0) diff --git a/artel/docgenerator/views.py b/artel/mailings/views.py similarity index 100% rename from artel/docgenerator/views.py rename to artel/mailings/views.py diff --git a/artel/requirements.txt b/artel/requirements.txt index 89d53de..2ea8975 100644 --- a/artel/requirements.txt +++ b/artel/requirements.txt @@ -1,10 +1,13 @@ Django>=4.1,<4.2 wagtail>=4.2,<4.3 wagtailmenus>=3.1.5,<=3.1.7 -psycopg2>=2.9.5,<=2.9.6 +psycopg2-binary>=2.9.5,<=2.9.6 dj-database-url<=2.0.0 djangorestframework==3.14.0 phonenumbers==8.13.13 django-phonenumber-field==7.1.0 factory-boy==3.2.1 -pdfkit==1.0.0 \ No newline at end of file +pdfkit==1.0.0 +num2words==0.5.12 +sentry-sdk==1.28.0 +pandas==2.0.3 diff --git a/artel/search/__pycache__/__init__.cpython-311.pyc b/artel/search/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index 4638d66..0000000 Binary files a/artel/search/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/artel/search/__pycache__/views.cpython-311.pyc b/artel/search/__pycache__/views.cpython-311.pyc deleted file mode 100644 index b93aac2..0000000 Binary files a/artel/search/__pycache__/views.cpython-311.pyc and /dev/null differ diff --git a/artel/store/admin.py b/artel/store/admin.py index afd0798..0a6aa7f 100644 --- a/artel/store/admin.py +++ b/artel/store/admin.py @@ -26,19 +26,30 @@ class ProductCategoryParamAdmin(ModelAdmin): class ProductTemplateAdmin(ModelAdmin): + menu_label = "Product design" model = models.ProductTemplate list_display = ("title", "code") class ProductAdmin(ModelAdmin): + menu_label = "Product variant" model = models.Product list_display = ("title", "price") +class PaymentMethodAdmin(ModelAdmin): + model = models.PaymentMethod + list_display = ("name", "active") + + +class DeliveryMethodAdmin(ModelAdmin): + model = models.DeliveryMethod + list_display = ("name", "active") + class DocumentTemplateAdmin(ModelAdmin): model = models.DocumentTemplate - list_display = ("name", "doc_type") + list_display = ("name", ) class StoreAdminGroup(ModelAdminGroup): @@ -51,7 +62,9 @@ class StoreAdminGroup(ModelAdminGroup): ProductCategoryParamAdmin, ProductTemplateAdmin, ProductAdmin, - DocumentTemplateAdmin + DocumentTemplateAdmin, + PaymentMethodAdmin, + DeliveryMethodAdmin ) diff --git a/artel/store/cart.py b/artel/store/cart.py index 814f879..39727a7 100644 --- a/artel/store/cart.py +++ b/artel/store/cart.py @@ -1,24 +1,31 @@ +import logging + from abc import ( ABC, - abstractmethod + abstractmethod, + abstractproperty +) +from typing import ( + List, + Any ) -from typing import List from dataclasses import dataclass from django.http.request import HttpRequest from django.conf import settings +from django.core import signing -from store.models import Product +from store.models import ( + Product, + ProductAuthor, + DeliveryMethod +) - -@dataclass -class CartItem: - product: Product - quantity: int +logger = logging.getLogger("cart_logger") class BaseCart(ABC): - def validate_item_id(self, item_id): + def validate_and_get_product(self, item_id): return Product.objects.get(id=item_id) @abstractmethod @@ -33,67 +40,137 @@ class BaseCart(ABC): def update_item_quantity(self, item_id, change): ... - @abstractmethod - def get_items(self): + @abstractproperty + def display_items(self): ... class SessionCart(BaseCart): - def __init__(self, request: HttpRequest) -> None: + + def _get_author_total_price(self, author_id: int): + author_cart = self._cart[str(author_id)] + author_price = 0 + product_ids = list(int(pk) for pk in author_cart.keys()) + queryset = Product.objects.filter(id__in=product_ids) + for product in queryset: + author_price += product.price * author_cart[str(product.id)] + + if self._delivery_info: + author_price += self._delivery_info.price + + return author_price + + def _prepare_display_items(self)-> List[dict[str, dict|str]]: + items: List[dict[str, dict|str]] = [] + for author_id, cart_items in self._cart.items(): + author = ProductAuthor.objects.get(id=int(author_id)) + products = [] + for item_id, quantity in cart_items.items(): + product=Product.objects.get(id=int(item_id)) + products.append({"product": product, "quantity": quantity}) + items.append({ + "author": author, + "products": products, + "group_price": self._get_author_total_price(author_id) + }) + return items + + def __init__(self, request: HttpRequest, delivery: DeliveryMethod=None) -> None: super().__init__() self.session = request.session - if not self.session.get(settings.CART_SESSION_ID): - self.session[settings.CART_SESSION_ID] = {} + self._cart = self.session.get(settings.CART_SESSION_ID, None) + if not self._cart: + self._cart = {} + self.session[settings.CART_SESSION_ID] = self._cart + self._delivery_info = delivery + self._display_items = self._prepare_display_items() + + def save_cart(self): + self._display_items = self._prepare_display_items() + self.session[settings.CART_SESSION_ID] = self._cart + self.session.modified = True def add_item(self, item_id: int, quantity: int) -> None: - # TODO - add logging - self.validate_item_id(item_id) + product = self.validate_and_get_product(item_id) + author = product.author quantity = int(quantity) item_id = int(item_id) - if not self.session[settings.CART_SESSION_ID].get(str(item_id)): - self.session[settings.CART_SESSION_ID][item_id] = quantity - self.session.modified = True + if not self._cart.get(str(author.id)): + self._cart[str(author.id)] = {str(item_id): quantity} + self.save_cart() + elif not self._cart[str(author.id)].get(str(item_id)): + self._cart[str(author.id)].update({str(item_id): quantity}) + self.save_cart() else: - self.update_item_quantity(item_id, quantity) + new_quantity = self._cart[str(author.id)][str(item_id)] + quantity + self.update_item_quantity(item_id, new_quantity) def remove_item(self, item_id: int) -> None: - self.validate_item_id(item_id) + product = self.validate_and_get_product(item_id) + author = product.author try: - self.session[settings.CART_SESSION_ID].pop(item_id) - self.session.modified = True + self._cart[str(author.id)].pop(str(item_id)) + self.save_cart() except KeyError: - # TODO - add logging - ... + logger.exception(f"Item {item_id} not found in cart") def update_item_quantity(self, item_id: int, new_quantity: int) -> None: - self.validate_item_id(item_id) + product = self.validate_and_get_product(item_id) + author = product.author if new_quantity < 1: self.remove_item(item_id) return - try: - self.session[settings.CART_SESSION_ID][str(item_id)] = new_quantity - self.session.modified = True - except KeyError: - # TODO - add logging + if not self._cart.get(str(author.id)): self.add_item(item_id, new_quantity) + return + self._cart[str(author.id)][str(product.id)] = new_quantity + self.save_cart() - def get_items(self) -> List[CartItem]: - _items = [] - for item_id, quantity in self.session[settings.CART_SESSION_ID].items(): - _items.append(CartItem(quantity=quantity, product=Product.objects.get(id=item_id))) - return _items + @property + def delivery_info(self): + return self._delivery_info + @property + def display_items(self) -> List[dict[str, dict|str]]: + return self._display_items + @property def total_price(self): total = 0 - for item in self.get_items(): - total += item.product.price * int(item.quantity) + for _, cart_items in self._cart.items(): + for item_id, quantity in cart_items.items(): + product = Product.objects.get(id=int(item_id)) + total += product.price * quantity + if self._delivery_info: + total += self._delivery_info.price * len(self._cart.keys()) return total def is_empty(self) -> bool: - return not bool(self.session[settings.CART_SESSION_ID].items()) + return not bool(self._cart.items()) def clear(self) -> None: - self.session[settings.CART_SESSION_ID] = {} - self.session.modified = True + self._cart = {} + self.save_cart() + + +class CustomerData: + + def _encrypt_data(self, data: dict[str, Any]) -> str: + signer = signing.Signer() + return signer.sign_object(data) + + def _decrypt_data(self, data: str) -> dict[str, Any]: + signer = signing.Signer() + return signer.unsign_object(data) + + def __init__(self, data: dict[str, Any]=None, encrypted_data: str=None) -> None: + self._data = self._encrypt_data(data) if data else encrypted_data + + @property + def data(self) -> dict[str, Any]: + return self._data + + @property + def decrypted_data(self) -> dict[str, Any]: + return self._decrypt_data(self._data) diff --git a/artel/store/forms.py b/artel/store/forms.py index 71c148e..64ab14f 100644 --- a/artel/store/forms.py +++ b/artel/store/forms.py @@ -1,19 +1,20 @@ from django import forms from phonenumber_field.formfields import PhoneNumberField -# from phonenumber_field.widgets import PhoneNumberPrefixWidget +from phonenumber_field.phonenumber import PhoneNumber +from django.db.models import Model from store.models import ( - CustomerData, + ProductTemplate, + ProductCategoryParamValue, + Product, + PaymentMethod, + DeliveryMethod ) -class CustomerDataForm(forms.ModelForm): - class Meta: - model = CustomerData - fields = [ - "name", "surname", "email", "phone", - "street", "city", "zip_code" - ] + + +class CustomerDataForm(forms.Form): name = forms.CharField( max_length=255, label="Imię", widget=forms.TextInput(attrs={"class": "form-control"}) @@ -41,3 +42,49 @@ class CustomerDataForm(forms.ModelForm): choices=(("PL", "Polska"), ), label="Kraj", widget=forms.Select(attrs={"class": "form-control"}) ) + payment_method = forms.ModelChoiceField( + queryset=PaymentMethod.objects.filter(active=True), label="Sposób płatności", + widget=forms.Select(attrs={"class": "form-control"}) + ) + delivery_method = forms.ModelChoiceField( + queryset=DeliveryMethod.objects.filter(active=True), label="Sposób dostawy", + widget=forms.Select(attrs={"class": "form-control"}) + ) + + def serialize(self): + """Clean method should return JSON serializable""" + new_cleaned_data = {} + for key, value in self.cleaned_data.items(): + if isinstance(value, PhoneNumber): + new_cleaned_data[key] = str(value) + elif isinstance(value, Model): + new_cleaned_data[key] = value.pk + else: + new_cleaned_data[key] = value + return new_cleaned_data + + +class ButtonToggleSelect(forms.RadioSelect): + template_name = "store/forms/button_toggle_select.html" + + +class ProductTemplateConfigForm(forms.Form): + + def _create_dynamic_fields(self, template: ProductTemplate): + category_params = template.category.category_params.all() + for param in category_params: + self.fields[param.key] = forms.ModelChoiceField( + queryset=ProductCategoryParamValue.objects.filter(param=param), + widget=ButtonToggleSelect(attrs={"class": "btn-group btn-group-toggle"}), + ) + + def __init__( + self, template: ProductTemplate, *args, **kwargs + ): + self.template = template + super().__init__(*args, **kwargs) + self._create_dynamic_fields(template) + + def get_product(self): + params = list(self.cleaned_data.values()) + return Product.objects.get_or_create_by_params(template=self.template, params=params) diff --git a/artel/store/loader.py b/artel/store/loader.py new file mode 100644 index 0000000..8174711 --- /dev/null +++ b/artel/store/loader.py @@ -0,0 +1,76 @@ +import logging +import requests +import pandas as pd + +from django.core import files + +from store.models import ( + ProductTemplate, + ProductCategoryParamValue, + Product, + ProductImage +) + + +logger = logging.getLogger(__name__) + + +class BaseLoader: + def __init__(self, path): + self.path = path + + def load_data(self): + return pd.read_csv(self.path) + + +class TemplateLoader(BaseLoader): + ... + + +class ProductLoader(BaseLoader): + + def _get_images(self, row) -> list[files.ContentFile]: + urls = row["images"] + images = [] + for url in urls: + response = requests.get(url, stream=True) + if response.status_code == 200: + data = response.raw + file_name = url.split("/")[-1] + image = files.ContentFile(data, name=file_name) + images.append(image) + return images + + def _process_row(self, row): + template = ProductTemplate.objects.get(code=row["template"]) + price = float(row["price"]) + name = row["name"] + available = bool(row["available"]) + params = [] + for param in row["params"]: + key, value = param + param = ProductCategoryParamValue.objects.get(param__key=key, value=value) + params.append(param) + product = Product.objects.get_or_create_by_params(template=template, params=params) + product.price = price + product.name = name + product.available = available + + images = self._get_images(row) + for i, image in enumerate(images): + ProductImage.objects.create(product=product, image=image, is_main=bool(i==0)) + product.save() + return product + + def process(self): + data = self.load_data() + products = [] + for _, row in data.iterrows(): + try: + product = self._process_row(row) + except Exception as e: + # catch any error and log it, GlitchTip will catch it + logger.exception(str(e)) + else: + products.append(product) + logger.info(f"Loaded {len(products)} products") diff --git a/artel/store/management/commands/load_products.py b/artel/store/management/commands/load_products.py new file mode 100644 index 0000000..a277ad0 --- /dev/null +++ b/artel/store/management/commands/load_products.py @@ -0,0 +1,13 @@ +from django.core.management import BaseCommand +from django.conf import settings + +from store.loader import ProductLoader + + + +class Command(BaseCommand): + help = "Load products from csv file" + + def handle(self, *args, **options): + loader = ProductLoader(settings.PRODUCTS_CSV_PATH) + loader.process() diff --git a/artel/store/migrations/0004_customerdata_order_orderproduct.py b/artel/store/migrations/0004_customerdata_order_orderproduct.py deleted file mode 100644 index 8aad27e..0000000 --- a/artel/store/migrations/0004_customerdata_order_orderproduct.py +++ /dev/null @@ -1,53 +0,0 @@ -# Generated by Django 4.1.9 on 2023-06-01 19:00 - -import django.core.validators -from django.db import migrations, models -import django.db.models.deletion -import phonenumber_field.modelfields - - -class Migration(migrations.Migration): - dependencies = [ - ("store", "0003_product_info_product_name_and_more"), - ] - - operations = [ - migrations.CreateModel( - name="CustomerData", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("name", models.CharField(max_length=255)), - ("surname", models.CharField(max_length=255)), - ("email", models.EmailField(max_length=254)), - ("phone", phonenumber_field.modelfields.PhoneNumberField(max_length=128, region=None)), - ("street", models.CharField(max_length=255)), - ("city", models.CharField(max_length=255)), - ("zip_code", models.CharField(max_length=120)), - ("country", models.CharField(max_length=120)), - ], - ), - migrations.CreateModel( - name="Order", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("sent", models.BooleanField(default=False)), - ("customer", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="store.customerdata")), - ], - ), - migrations.CreateModel( - name="OrderProduct", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("quantity", models.IntegerField(validators=[django.core.validators.MinValueValidator(1)])), - ( - "order", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, related_name="products", to="store.order" - ), - ), - ("product", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="store.product")), - ], - ), - ] diff --git a/artel/store/migrations/0004_documenttemplate_order_paymentmethod_and_more.py b/artel/store/migrations/0004_documenttemplate_order_paymentmethod_and_more.py new file mode 100644 index 0000000..7fc6650 --- /dev/null +++ b/artel/store/migrations/0004_documenttemplate_order_paymentmethod_and_more.py @@ -0,0 +1,129 @@ +# Generated by Django 4.1.9 on 2023-06-16 15:33 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import phonenumber_field.modelfields + + +class Migration(migrations.Migration): + dependencies = [ + ("store", "0003_product_info_product_name_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="DocumentTemplate", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=255)), + ("file", models.FileField(upload_to="documents")), + ( + "doc_type", + models.CharField( + choices=[("agreement", "Agreement"), ("receipt", "Receipt")], max_length=255, unique=True + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True, null=True)), + ], + ), + migrations.CreateModel( + name="Order", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("sent", models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name="PaymentMethod", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True)), + ("active", models.BooleanField(default=True)), + ], + ), + migrations.AddField( + model_name="productauthor", + name="city", + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name="productauthor", + name="country", + field=models.CharField(blank=True, max_length=120), + ), + migrations.AddField( + model_name="productauthor", + name="display_name", + field=models.CharField(blank=True, max_length=255, unique=True), + ), + migrations.AddField( + model_name="productauthor", + name="email", + field=models.EmailField(blank=True, max_length=254), + ), + migrations.AddField( + model_name="productauthor", + name="phone", + field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, region=None), + ), + migrations.AddField( + model_name="productauthor", + name="street", + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name="productauthor", + name="surname", + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name="productauthor", + name="zip_code", + field=models.CharField(blank=True, max_length=120), + ), + migrations.AlterField( + model_name="productauthor", + name="name", + field=models.CharField(blank=True, max_length=255), + ), + migrations.CreateModel( + name="OrderProduct", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("quantity", models.IntegerField(validators=[django.core.validators.MinValueValidator(1)])), + ( + "order", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="products", to="store.order" + ), + ), + ("product", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="store.product")), + ], + ), + migrations.CreateModel( + name="OrderDocument", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("sent", models.BooleanField(default=False)), + ( + "order", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="documents", to="store.order" + ), + ), + ( + "template", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="store.documenttemplate"), + ), + ], + ), + migrations.AddField( + model_name="order", + name="payment_method", + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="store.paymentmethod"), + ), + ] diff --git a/artel/store/migrations/0005_documenttemplate_orderdocument.py b/artel/store/migrations/0005_documenttemplate_orderdocument.py deleted file mode 100644 index deb0a75..0000000 --- a/artel/store/migrations/0005_documenttemplate_orderdocument.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 4.1.9 on 2023-06-04 09:38 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - dependencies = [ - ("store", "0004_customerdata_order_orderproduct"), - ] - - operations = [ - migrations.CreateModel( - name="DocumentTemplate", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("name", models.CharField(max_length=255)), - ("file", models.FileField(upload_to="documents")), - ( - "doc_type", - models.CharField(choices=[("agreement", "Agreement"), ("receipt", "Receipt")], max_length=255), - ), - ], - ), - migrations.CreateModel( - name="OrderDocument", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ( - "order", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, related_name="documents", to="store.order" - ), - ), - ( - "template", - models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="store.documenttemplate"), - ), - ], - ), - ] diff --git a/artel/store/migrations/0005_order_order_number.py b/artel/store/migrations/0005_order_order_number.py new file mode 100644 index 0000000..eb07ce3 --- /dev/null +++ b/artel/store/migrations/0005_order_order_number.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.9 on 2023-06-18 11:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("store", "0004_documenttemplate_order_paymentmethod_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="order", + name="order_number", + field=models.CharField(max_length=255, null=True), + ), + ] diff --git a/artel/store/migrations/0006_documenttemplate_created_at_orderdocument_sent.py b/artel/store/migrations/0006_documenttemplate_created_at_orderdocument_sent.py deleted file mode 100644 index 8b2de2d..0000000 --- a/artel/store/migrations/0006_documenttemplate_created_at_orderdocument_sent.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.1.9 on 2023-06-07 23:16 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("store", "0005_documenttemplate_orderdocument"), - ] - - operations = [ - migrations.AddField( - model_name="documenttemplate", - name="created_at", - field=models.DateTimeField(auto_now_add=True, null=True), - ), - migrations.AddField( - model_name="orderdocument", - name="sent", - field=models.BooleanField(default=False), - ), - ] diff --git a/artel/store/migrations/0006_remove_orderdocument_sent.py b/artel/store/migrations/0006_remove_orderdocument_sent.py new file mode 100644 index 0000000..65b8771 --- /dev/null +++ b/artel/store/migrations/0006_remove_orderdocument_sent.py @@ -0,0 +1,16 @@ +# Generated by Django 4.1.9 on 2023-06-22 16:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("store", "0005_order_order_number"), + ] + + operations = [ + migrations.RemoveField( + model_name="orderdocument", + name="sent", + ), + ] diff --git a/artel/store/migrations/0007_rename_productimage_producttemplateimage.py b/artel/store/migrations/0007_rename_productimage_producttemplateimage.py new file mode 100644 index 0000000..e6bc809 --- /dev/null +++ b/artel/store/migrations/0007_rename_productimage_producttemplateimage.py @@ -0,0 +1,16 @@ +# Generated by Django 4.1.9 on 2023-06-25 08:05 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("store", "0006_remove_orderdocument_sent"), + ] + + operations = [ + migrations.RenameModel( + old_name="ProductImage", + new_name="ProductTemplateImage", + ), + ] diff --git a/artel/store/migrations/0008_remove_product_info_producttemplateimage_is_main_and_more.py b/artel/store/migrations/0008_remove_product_info_producttemplateimage_is_main_and_more.py new file mode 100644 index 0000000..fdc2854 --- /dev/null +++ b/artel/store/migrations/0008_remove_product_info_producttemplateimage_is_main_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 4.1.9 on 2023-06-25 19:24 + +from django.db import migrations, models +import django.db.models.deletion +import modelcluster.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("store", "0007_rename_productimage_producttemplateimage"), + ] + + operations = [ + migrations.RemoveField( + model_name="product", + name="info", + ), + migrations.AddField( + model_name="producttemplateimage", + name="is_main", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="producttemplateimage", + name="template", + field=modelcluster.fields.ParentalKey( + on_delete=django.db.models.deletion.CASCADE, related_name="template_images", to="store.producttemplate" + ), + ), + migrations.CreateModel( + name="ProductImage", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("image", models.ImageField(upload_to="")), + ("is_main", models.BooleanField(default=False)), + ( + "product", + modelcluster.fields.ParentalKey( + on_delete=django.db.models.deletion.CASCADE, related_name="product_images", to="store.product" + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/artel/store/migrations/0009_productcategoryparam_order_productcategoryparamvalue.py b/artel/store/migrations/0009_productcategoryparam_order_productcategoryparamvalue.py new file mode 100644 index 0000000..eb86508 --- /dev/null +++ b/artel/store/migrations/0009_productcategoryparam_order_productcategoryparamvalue.py @@ -0,0 +1,32 @@ +# Generated by Django 4.1.9 on 2023-06-30 16:11 + +from django.db import migrations, models +import django.db.models.deletion +import modelcluster.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("store", "0008_remove_product_info_producttemplateimage_is_main_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="ProductCategoryParamValue", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("value", models.CharField(max_length=255)), + ( + "param", + modelcluster.fields.ParentalKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="param_values", + to="store.productcategoryparam", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/artel/store/migrations/0010_auto_20230630_1611.py b/artel/store/migrations/0010_auto_20230630_1611.py new file mode 100644 index 0000000..c4938f9 --- /dev/null +++ b/artel/store/migrations/0010_auto_20230630_1611.py @@ -0,0 +1,29 @@ +# Generated by Django 4.1.9 on 2023-06-30 16:11 + +from django.db import migrations + + +def copy_old_data(apps, schema_editor): + TemplateParamValue = apps.get_model("store", "TemplateParamValue") + ProductCategoryParamValue = apps.get_model("store", "ProductCategoryParamValue") + + for param_value in TemplateParamValue.objects.all(): + ProductCategoryParamValue.objects.create( + param=param_value.param, + value=param_value.value + ) + + +def remove_new_data(apps, schema_editor): + ProductCategoryParamValue = apps.get_model("store", "ProductCategoryParamValue") + ProductCategoryParamValue.objects.all().delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("store", "0009_productcategoryparam_order_productcategoryparamvalue"), + ] + + operations = [ + migrations.RunPython(copy_old_data, remove_new_data), + ] diff --git a/artel/store/migrations/0011_productparam_delete_templateparamvalue_and_more.py b/artel/store/migrations/0011_productparam_delete_templateparamvalue_and_more.py new file mode 100644 index 0000000..1bceea9 --- /dev/null +++ b/artel/store/migrations/0011_productparam_delete_templateparamvalue_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 4.1.9 on 2023-07-02 09:34 + +from django.db import migrations, models +import django.db.models.deletion +import modelcluster.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("store", "0010_auto_20230630_1611"), + ] + + operations = [ + migrations.CreateModel( + name="ProductParam", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "param_value", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="store.productcategoryparamvalue" + ), + ), + ( + "product", + modelcluster.fields.ParentalKey( + on_delete=django.db.models.deletion.CASCADE, related_name="product_params", to="store.product" + ), + ), + ], + ), + migrations.DeleteModel( + name="TemplateParamValue", + ), + migrations.AddField( + model_name="product", + name="params", + field=models.ManyToManyField( + blank=True, through="store.ProductParam", to="store.productcategoryparamvalue" + ), + ), + ] diff --git a/artel/store/migrations/0012_deliverymethod_order_uuid_product_uuid_and_more.py b/artel/store/migrations/0012_deliverymethod_order_uuid_product_uuid_and_more.py new file mode 100644 index 0000000..f3ad473 --- /dev/null +++ b/artel/store/migrations/0012_deliverymethod_order_uuid_product_uuid_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 4.1.9 on 2023-07-22 17:18 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("store", "0011_productparam_delete_templateparamvalue_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="DeliveryMethod", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=255)), + ("description", models.TextField(blank=True)), + ("price", models.FloatField(default=0)), + ("active", models.BooleanField(default=True)), + ], + ), + migrations.AddField( + model_name="order", + name="uuid", + field=models.UUIDField(default=uuid.uuid4, editable=False), + ), + migrations.AddField( + model_name="product", + name="uuid", + field=models.UUIDField(default=uuid.uuid4, editable=False), + ), + migrations.AddField( + model_name="order", + name="delivery_method", + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to="store.deliverymethod"), + ), + ] diff --git a/artel/store/models.py b/artel/store/models.py index 720311f..2919cf9 100644 --- a/artel/store/models.py +++ b/artel/store/models.py @@ -1,4 +1,14 @@ import pdfkit +import datetime +import builtins +import uuid +import logging + +from decimal import Decimal +from typing import ( + Any, + Iterator +) from django.db import models from django.core.paginator import ( Paginator, @@ -10,6 +20,9 @@ from django.template import ( Template, Context ) +from django.core.exceptions import ValidationError +from django.db.models.signals import m2m_changed +from django.forms import CheckboxSelectMultiple from modelcluster.models import ClusterableModel from modelcluster.fields import ParentalKey @@ -21,18 +34,53 @@ from wagtail.models import Page from wagtail import fields as wagtail_fields from taggit.managers import TaggableManager from phonenumber_field.modelfields import PhoneNumberField +from num2words import num2words -from store.utils import ( - send_mail +from mailings.models import ( + OutgoingEmail, + Attachment ) -class ProductAuthor(models.Model): - name = models.CharField(max_length=255) - # TODO - add author contact data +logger = logging.getLogger(__name__) + + +class BaseImageModel(models.Model): + image = models.ImageField() + is_main = models.BooleanField(default=False) + + class Meta: + abstract = True + + +class PersonalData(models.Model): + + class Meta: + abstract = True + + name = models.CharField(max_length=255, blank=True) + surname = models.CharField(max_length=255, blank=True) + email = models.EmailField(blank=True) + phone = PhoneNumberField(blank=True) + street = models.CharField(max_length=255, blank=True) + city = models.CharField(max_length=255, blank=True) + zip_code = models.CharField(max_length=120, blank=True) + country = models.CharField(max_length=120, blank=True) + + @property + def full_name(self): + return f"{self.name} {self.surname}" + + @property + def full_address(self): + return f"{self.street}, {self.zip_code} {self.city}, {self.country}" + + +class ProductAuthor(PersonalData): + display_name = models.CharField(max_length=255, unique=True, blank=True) def __str__(self): - return self.name + return self.display_name class ProductCategory(ClusterableModel): @@ -60,6 +108,32 @@ class ProductCategoryParam(ClusterableModel): def __str__(self): return self.key + + panels = [ + FieldPanel("category"), + FieldPanel("key"), + FieldPanel("param_type"), + InlinePanel("param_values") + ] + + def get_available_values(self) -> Iterator[any]: + for elem in self.param_values.all(): + yield elem.get_value() + + +class ProductCategoryParamValue(ClusterableModel): + param = ParentalKey(ProductCategoryParam, on_delete=models.CASCADE, related_name="param_values") + value = models.CharField(max_length=255) + + def get_value(self): + try: + func = getattr(builtins, self.param.param_type) + return func(self.value) + except ValueError: + return + + def __str__(self): + return f"{self.param.key}: {self.value}" class ProductTemplate(ClusterableModel): @@ -68,57 +142,107 @@ class ProductTemplate(ClusterableModel): title = models.CharField(max_length=255) code = models.CharField(max_length=255) description = models.TextField(blank=True) + # TODO - add mechanism for enabling params tags = TaggableManager() def __str__(self): return self.title + @property + def main_image(self): + try: + return self.template_images.get(is_main=True) + except ProductImage.DoesNotExist: + return self.template_images.first() + panels = [ FieldPanel("category"), FieldPanel("author"), FieldPanel('title'), FieldPanel('code'), FieldPanel('description'), - InlinePanel("images"), + InlinePanel("template_images", label="Template Images"), FieldPanel("tags"), ] -class ProductImage(models.Model): +class ProductTemplateImage(BaseImageModel): template = ParentalKey( - ProductTemplate, on_delete=models.CASCADE, related_name="images" + ProductTemplate, on_delete=models.CASCADE, related_name="template_images" ) image = models.ImageField() + is_main = models.BooleanField(default=False) + + +class ProductManager(models.Manager): + + def get_or_create_by_params(self, params: list[ProductCategoryParamValue], template: ProductTemplate): + products = self.filter(template=template) + + for param in params: + products = products.filter(params__pk=param.pk) + + # There should be only one + if not products.count() <= 1: + logger.exception( + f"There should be only one product with given set of params, detected: " + + f"{products.count()}, params: {params}, template: {template}" + ) + + product = products.first() + if not product: + product = self.create( + name=f"{template.title} - AUTOGENERATED", + template=template, + price=0, + available=False + ) + for param in params: + product.params.add(param) + + return product class Product(ClusterableModel): name = models.CharField(max_length=255, blank=True) - info = models.TextField(blank=True) template = models.ForeignKey(ProductTemplate, on_delete=models.CASCADE, related_name="products") + params = models.ManyToManyField( + ProductCategoryParamValue, blank=True, through="ProductParam", + limit_choices_to=models.Q(param__category=models.F("product__template__category")) + ) price = models.FloatField() available = models.BooleanField(default=True) + uuid = models.UUIDField(default=uuid.uuid4, editable=False) + + objects = ProductManager() panels = [ FieldPanel("template"), FieldPanel("price"), - InlinePanel("param_values"), + FieldPanel("params", widget=CheckboxSelectMultiple), FieldPanel("available"), FieldPanel("name"), - FieldPanel("info") + InlinePanel("product_images", label="Variant Images"), ] @property def main_image(self): - images = self.template.images.all() - print(images) - if images: - return images.first().image + try: + return self.product_images.get(is_main=True) + except ProductImage.DoesNotExist: + if main_image := self.template.main_image: + return main_image + return self.product_images.first() @property def tags(self): return self.template.tags.all() + @property + def author(self): + return self.template.author + @property def description(self): return self.info or self.template.description @@ -128,10 +252,43 @@ class Product(ClusterableModel): return self.name or self.template.title -class TemplateParamValue(models.Model): - param = models.ForeignKey(ProductCategoryParam, on_delete=models.CASCADE) - product = ParentalKey(Product, on_delete=models.CASCADE, related_name="param_values") - value = models.CharField(max_length=255) +class ProductImage(BaseImageModel): + product = ParentalKey( + "Product", on_delete=models.CASCADE, related_name="product_images" + ) + + +class ProductParam(models.Model): + product = ParentalKey(Product, on_delete=models.CASCADE, related_name="product_params") + param_value = models.ForeignKey(ProductCategoryParamValue, on_delete=models.CASCADE) + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) + + +# SIGNALS +def validate_param(sender, **kwargs): + action = kwargs.pop("action") + if action != "pre_add": + return + pk_set = kwargs.get("pk_set") + product_instance = kwargs.get("instance") + errors = [] + for pk in pk_set: + try: + param = ProductCategoryParamValue.objects.get(pk=pk).param + except ProductCategoryParamValue.DoesNotExist as e: + logger.exception(f"Product param validation failed with exception: {str(e)}") + count = product_instance.params.filter(productparam__param_value__param=param).count() + if count >= 1: + errors.append(ValueError("Product param with this key already exists.")) + + if errors: + raise ValidationError(errors) + + +m2m_changed.connect(validate_param, Product.params.through) class ProductListPage(Page): @@ -141,10 +298,12 @@ class ProductListPage(Page): tags = TaggableManager(blank=True) def _get_items(self): + if not self.pk: + return ProductTemplate.objects.all() if self.tags.all(): - return Product.objects.filter(available=True, template__tags__in=self.tags.all()) - return Product.objects.filter(available=True) - + return ProductTemplate.objects.filter(tags__in=self.tags.all()) + return ProductTemplate.objects.all() + def get_context(self, request): context = super().get_context(request) items = self._get_items() @@ -166,33 +325,25 @@ class ProductListPage(Page): FieldPanel("tags") ] -class CustomerData(models.Model): - name = models.CharField(max_length=255) - surname = models.CharField(max_length=255) - email = models.EmailField() - phone = PhoneNumberField() - street = models.CharField(max_length=255) - city = models.CharField(max_length=255) - zip_code = models.CharField(max_length=120) - country = models.CharField(max_length=120) - - @property - def full_name(self): - return f"{self.name} {self.surname}" - - @property - def full_address(self): - return f"{self.street}, {self.zip_code} {self.city}, {self.country}" - class OrderProductManager(models.Manager): - def create_from_cart(self, cart, order): - for item in cart.get_items(): - self.create( - product=item.product, + def create_from_cart(self, items: dict[str, Product|int], order: models.Model): + pks = [] + for item in items: + if item["quantity"] < 1: + logger.exception( + f"This is not possible to add less than one item to Order, omitting item: "+ + f"{item['product']}" + ) + continue + + pk = self.create( + product=item["product"], order=order, - quantity=item.quantity - ) + quantity=item["quantity"] + ).pk + pks.append(pk) + return self.filter(pk__in=pks) class OrderProduct(models.Model): @@ -204,41 +355,143 @@ class OrderProduct(models.Model): class OrderManager(models.Manager): - def create_from_cart(self, cart, customer_data): - order = self.create(customer=customer_data) - OrderProduct.objects.create_from_cart(cart, order) - # create proper documents - # NOTE - this is temporary - # agreement_template = DocumentTemplate.objects.filter( - # doc_type=DocumentTypeChoices.AGREEMENT - # ).order_by("-created_at").first() - # receipt_template = DocumentTemplate.objects.filter( - # doc_type=DocumentTypeChoices.RECEIPT - # ).order_by("-created_at").first() - # agreement = OrderDocument.objects.create( - # order=order, - # template=agreement_template - # ) - # receipt = OrderDocument.objects.create( - # order=order, - # template=receipt_template - # ) - #send_mail(agreement) - #send_mail(receipt) - return order + + def _get_order_number(self, author: ProductAuthor): + number_of_prev_orders = OrderProduct.objects.filter( + product__template__author=author + ).values("order").distinct().count() + number_of_prev_orders += 1 + year = datetime.datetime.now().year + return f"{author.id}/{number_of_prev_orders:06}/{year}" + + def _send_notifications( + self, order: models.Model, author: ProductAuthor, + customer_data: dict[str, Any], docs: list[models.Model] + ): + # for user + attachments = [ + Attachment( + content=doc.generate_document({"customer_data": customer_data}), + contenttype="application/pdf", + name=f"{doc.template.doc_type}_{order.order_number}.pdf" + ) for doc in docs + ] + mail_subject = f"Wygenerowano umowę numer {order.order_number} z dnia {order.created_at.strftime('%d.%m.%Y')}" + user_mail = OutgoingEmail.objects.send( + recipient=customer_data["email"], + subject=mail_subject, + context = { + "docs": docs, + "order_number": order.order_number, + "customer_email": customer_data["email"], + }, sender=settings.DEFAULT_FROM_EMAIL, + template_name="order_created_user", + attachments=attachments + ) + # for author + author_mail = OutgoingEmail.objects.send( + recipient=author.email, + subject=mail_subject, + context = { + "docs": docs, + "order_number": order.order_number, + "manufacturer_email": author.email, + }, sender=settings.DEFAULT_FROM_EMAIL, + template_name="order_created_author", + attachments=attachments + ) + return user_mail is not None and author_mail is not None + + def create_from_cart( + self, cart_items: list[dict[str, str|dict]], + payment_method: models.Model| None, + customer_data: dict[str, Any] + ) -> models.QuerySet: + # split cart + orders_pks = [] + + payment_method = payment_method or PaymentMethod.objects.first() + doc_templates = DocumentTemplate.objects.filter( + doc_type__in=[DocumentTypeChoices.AGREEMENT, DocumentTypeChoices.RECEIPT] + ) + + for item in cart_items: + author = item["author"] + author_products = item["products"] + + order = self.create( + payment_method=payment_method, + order_number=self._get_order_number(author) + ) + OrderProduct.objects.create_from_cart(author_products, order) + orders_pks.append(order.pk) + docs = [] + for template in doc_templates: + doc = OrderDocument.objects.create( + order=order, template=template + ) + docs.append(doc) + sent = self._send_notifications(order, author, customer_data, docs) + + if not sent: + logger.exception( + f"Error while sending emails, for order: {order.order_number}" + ) + + return Order.objects.filter(pk__in=orders_pks) + + +class PaymentMethod(models.Model): + name = models.CharField(max_length=255) + + description = models.TextField(blank=True) + active = models.BooleanField(default=True) + + def __str__(self) -> str: + return self.name + + +class DeliveryMethod(models.Model): + name = models.CharField(max_length=255) + description = models.TextField(blank=True) + price = models.FloatField(default=0) + active = models.BooleanField(default=True) + + def __str__(self) -> str: + return self.name class Order(models.Model): - customer = models.ForeignKey(CustomerData, on_delete=models.CASCADE) + payment_method = models.ForeignKey(PaymentMethod, on_delete=models.CASCADE) + delivery_method = models.ForeignKey(DeliveryMethod, on_delete=models.CASCADE, null=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) sent = models.BooleanField(default=False) - + order_number = models.CharField(max_length=255, null=True) + + uuid = models.UUIDField(default=uuid.uuid4, editable=False) objects = OrderManager() @property - def order_number(self) -> str: - return f"{self.id:06}/{self.created_at.year}" + def manufacturer(self) -> str: + return self.products.first().product.author + + @property + def total_price(self) -> Decimal: + price = sum( + [order_product.product.price * order_product.quantity + for order_product in self.products.all()] + ) + delivery_price = self.delivery_method.price if self.delivery_method else 5.0 + return price + delivery_price + + @property + def total_price_words(self) -> str: + return num2words(self.total_price, lang="pl", to="currency", currency="PLN") + + @property + def payment_date(self) -> datetime.date: + return self.created_at.date() + datetime.timedelta(days=7) class DocumentTypeChoices(models.TextChoices): @@ -249,7 +502,8 @@ class DocumentTypeChoices(models.TextChoices): class DocumentTemplate(models.Model): name = models.CharField(max_length=255) file = models.FileField(upload_to="documents") - doc_type = models.CharField(max_length=255, choices=DocumentTypeChoices.choices) + # there may be only one document of each type + doc_type = models.CharField(max_length=255, choices=DocumentTypeChoices.choices, unique=True) created_at = models.DateTimeField(auto_now_add=True, null=True) def __str__(self): @@ -259,21 +513,23 @@ class DocumentTemplate(models.Model): class OrderDocument(models.Model): order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="documents") template = models.ForeignKey(DocumentTemplate, on_delete=models.CASCADE) - sent = models.BooleanField(default=False) def get_document_context(self): _context = { "order": self.order, - "customer": self.order.customer, - "products": self.order.products.all(), + "author": self.order.manufacturer, + "order_products": self.order.products.all(), + "payment_data": self.order.payment_method, } return Context(_context) - @property - def document(self): - with open(self.template.file.path, "rb") as f: + def generate_document(self, extra_context: dict = None): + extra_context = extra_context or {} + context = self.get_document_context() + context.update(extra_context) + + with open(self.template.file.path, "r", encoding="utf-8") as f: content = f.read() template = Template(content) - context = self.get_document_context() content = template.render(context) return pdfkit.from_string(content, False) diff --git a/artel/store/serializers.py b/artel/store/serializers.py index 584bb49..682a68f 100644 --- a/artel/store/serializers.py +++ b/artel/store/serializers.py @@ -1,6 +1,9 @@ from rest_framework import serializers -from store.models import Product +from store.models import ( + Product, + ProductAuthor +) class TagSerializer(serializers.Serializer): @@ -21,6 +24,17 @@ class CartProductSerializer(serializers.Serializer): quantity = serializers.IntegerField() +class ProductAuthorSerializer(serializers.Serializer): + class Meta: + model = ProductAuthor + fields = ["display_name"] + + +class CartSerializer(serializers.Serializer): + author = ProductAuthorSerializer() + products = CartProductSerializer(many=True) + + class CartProductAddSerializer(serializers.Serializer): product_id = serializers.IntegerField() diff --git a/artel/store/static/images/icons/truck.svg b/artel/store/static/images/icons/truck.svg new file mode 100644 index 0000000..1afc549 --- /dev/null +++ b/artel/store/static/images/icons/truck.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/artel/store/static/js/cart.js b/artel/store/static/js/cart.js index 34cf520..b0c1261 100644 --- a/artel/store/static/js/cart.js +++ b/artel/store/static/js/cart.js @@ -10,7 +10,7 @@ $(document).on('click', '.add-to-cart-button', function(event) { const csrfToken = $(this).data('csrf-token'); console.log(productID); formData.append('product_id', productID); - formData.append('quantity', quantity); // Serialize the form data correctly + formData.append('quantity', 1); // Serialize the form data correctly button.prop('disabled', true); $.ajax({ type: 'POST', @@ -133,7 +133,9 @@ $(document).on('click', '.add-to-cart-button', function(event) { data: formData, // Use the serialized form data headers: { 'X-CSRFToken': csrfToken }, dataType: 'json', - success: location.reload(), + success: function(data) { + setTimeout(location.reload(), 500) + }, processData: false, // Prevent jQuery from processing the data contentType: false, // Let the browser set the content type }); diff --git a/artel/store/static/js/product_configurator.js b/artel/store/static/js/product_configurator.js new file mode 100644 index 0000000..e69de29 diff --git a/artel/store/tasks.py b/artel/store/tasks.py new file mode 100644 index 0000000..cebc7f4 --- /dev/null +++ b/artel/store/tasks.py @@ -0,0 +1,32 @@ +import logging +from django.conf import settings + +from mailings.models import OutgoingEmail +from store.models import Product +from store.admin import ProductAdmin + + +logger = logging.getLogger(__name__) + +# TODO - those should be modified to be celery tasks + +def send_produt_request_email(variant_pk: int): + try: + variant = Product.objects.get(pk=variant_pk) + except Product.DoesNotExist: + logger.exception(f"Product with pk={variant_pk} does not exist") + + try: + admin_url = ProductAdmin().url_helper.get_action_url("edit", variant.pk) + send = OutgoingEmail.objects.send( + template_name="product_request", + subject="Złożono zapytanie ofertowe", + recipient=variant.template.author.email, + context={"product": variant, "admin_url": admin_url}, + sender=settings.DEFAULT_FROM_EMAIL + ) + except Exception as e: + logger.exception(f"Could not send email for variant pk={variant_pk}, exception: {e} has occured") + else: + if not send: + logger.exception(f"Could not send email for variant pk={variant_pk}") diff --git a/artel/store/templates/store/cart.html b/artel/store/templates/store/cart.html index 5ecf9b3..b19a5cc 100644 --- a/artel/store/templates/store/cart.html +++ b/artel/store/templates/store/cart.html @@ -9,15 +9,20 @@

Koszyk

- {% for item in cart.get_items %} - {% include 'store/partials/cart_item.html' %} + {% for group in cart.display_items %} + {% if group.products %} +

Wykonawca: {{group.author.display_name}}

+ {% for item in group.products %} + {% include 'store/partials/cart_item.html' %} + {% endfor %} + {% endif %} {% endfor %}
-
Do zapłaty: {{cart.total_price}}
+
W sumie do zapłaty: {{cart.total_price}}
Dalej diff --git a/artel/store/templates/store/configure_product.html b/artel/store/templates/store/configure_product.html new file mode 100644 index 0000000..4e59610 --- /dev/null +++ b/artel/store/templates/store/configure_product.html @@ -0,0 +1,47 @@ +{% extends 'base.html' %} + +{% block content %} +
+ +
+

{{template.title}}

+
+ +
+
+
+
+ Responsive image +
+
+

{{template.description}}

+
+
+ +
+ +
+
+ {% csrf_token %} +
+ {% for field in form %} +
+

{{field.label}}

+ {{field}} +
+ {% if forloop.counter|divisibleby:"2" %}
{% endif %} + {% endfor %} +
+
+
+
+ +
+
+
+
+
+
+
+ +{% endblock %} diff --git a/artel/store/templates/store/configure_product_summary.html b/artel/store/templates/store/configure_product_summary.html new file mode 100644 index 0000000..a44a4b2 --- /dev/null +++ b/artel/store/templates/store/configure_product_summary.html @@ -0,0 +1,80 @@ +{% extends 'base.html' %} + +{% block content %} + + +
+
+
+
+ Responsive image +
+
+
+
+ {% for value in params_values %} +
+
+

{{value}}

+
+
+
+ {% endfor %} +
+
+
+
+ {% if not variant.available %} +
+
+ Niestety skonfigurowany przez Ciebie wariant produktu nie jest jeszcze dostępny. + Jeżeli jesteś zainteresowany tą konfiguracją złóż zapytanie ofertowe. +
+
+ {% else %} +
+
+

Cena: {{variant.price}} zł

+
+
+ {% endif %} +
+ +
+ + {% if variant.available %} + + {% else %} +
+ {% csrf_token %} + +
+ {% endif %} +
+
+
+
+ +{% endblock %} diff --git a/artel/store/templates/store/forms/button_toggle_select.html b/artel/store/templates/store/forms/button_toggle_select.html new file mode 100644 index 0000000..2d5e4b6 --- /dev/null +++ b/artel/store/templates/store/forms/button_toggle_select.html @@ -0,0 +1,28 @@ + +{% with id=widget.attrs.id %} +
+ {% for group, options, index in widget.optgroups %} + {% for option in options %} + + + {% endfor %} + {% endfor %} +
+{% endwith %} + diff --git a/artel/store/templates/store/order.html b/artel/store/templates/store/order.html index 9e8ec8f..0776346 100644 --- a/artel/store/templates/store/order.html +++ b/artel/store/templates/store/order.html @@ -8,9 +8,9 @@
-

Twoje dane

-
+

Twoje dane

+
@@ -42,12 +42,13 @@
-
- -
- +
+
+

Dane do wysyłki

-
+
+
+
@@ -72,7 +73,30 @@ {{form.zip_code}}
+
+
+
+

Płatność i wysyłka

+
+
+
+
+
+
+ + {{form.payment_method}} +
+
+
+
+ + {{form.delivery_method}} +
+
+
+ +
diff --git a/artel/store/templates/store/order_confirm.html b/artel/store/templates/store/order_confirm.html index 4c1ee87..bca41d1 100644 --- a/artel/store/templates/store/order_confirm.html +++ b/artel/store/templates/store/order_confirm.html @@ -14,7 +14,7 @@

Imię i Nazwisko

-

{{customer_data.full_name}}

+

{{customer_data.name}} {{customer_data.surname}}


@@ -41,7 +41,10 @@

Adres

-

{{customer_data.full_address}}

+

+ {{customer_data.city}}, {{customer_data.zip_code}}
+ {{customer_data.street}} +

@@ -54,15 +57,28 @@

Zamówione przedmioty

- {% for item in cart.get_items %} - {% include 'store/partials/summary_cart_item.html' %} + {% for group in cart.display_items %} + {% if group.products %} +

Wykonawca: {{group.author.display_name}}

+ {% for item in group.products %} + {% include 'store/partials/summary_cart_item.html' %} + {% endfor %} + {% if cart.delivery_info %} + {% with delivery=cart.delivery_info %} + {% include 'store/partials/delivery_cart_item.html' %} + {% endwith %} + {% endif %} +
+
W sumie: {{group.group_price}} zł
+
+ {% endif %} {% endfor %} -
+
-
Do zapłaty: {{cart.total_price}}
+
Do zapłaty: {{cart.total_price}} zł
diff --git a/artel/store/templates/store/order_success.html b/artel/store/templates/store/order_success.html new file mode 100644 index 0000000..b0c3efe --- /dev/null +++ b/artel/store/templates/store/order_success.html @@ -0,0 +1,33 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+
+ + + + +
+
+

Dziękujemy za zakup!

+
+ Twoje zamówienia zostały przekazane do realizacji. + Oczekuj maili z dokumentami to zamówień.
+ + Twoje numery zamówień: +
+ {% for order in orders %} + {{order.order_number}}
+ {% endfor %} +
+ +
+ + Powrót do sklepu + +
+
+{% endblock %} diff --git a/artel/store/templates/store/partials/cart_item.html b/artel/store/templates/store/partials/cart_item.html index 52c2c41..c2be5d9 100644 --- a/artel/store/templates/store/partials/cart_item.html +++ b/artel/store/templates/store/partials/cart_item.html @@ -5,7 +5,7 @@
diff --git a/artel/store/templates/store/partials/delivery_cart_item.html b/artel/store/templates/store/partials/delivery_cart_item.html new file mode 100644 index 0000000..2488e7e --- /dev/null +++ b/artel/store/templates/store/partials/delivery_cart_item.html @@ -0,0 +1,25 @@ +{% load static %} + +
+
+
+
+ +
+
+

{{delivery.name}}

+
+
+ 1 +
+
+
{{delivery.price}} zł
+
+ +
+
+ +
+ \ No newline at end of file diff --git a/artel/store/templates/store/partials/product_card.html b/artel/store/templates/store/partials/product_card.html index a67c7e9..b3f72ee 100644 --- a/artel/store/templates/store/partials/product_card.html +++ b/artel/store/templates/store/partials/product_card.html @@ -3,21 +3,12 @@
{{item.title}}
- {{item.title}} + {{item.title}} -