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 %}
+
+ {{message}}
+
+ {% 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.description}}
+
+
+
+
+
+
+
+
+
+
+{% 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 %}
+
+
+
+
+
+
Przedmiot dodany do koszyka
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% for value in params_values %}
+
+
+ {% 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 %}
+
+ Zamów produkt
+
+ {% else %}
+
+ {% 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 %}
+
+ {{option.label}}
+ {% 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
+
-
-
-
-
Dane Kontaktowe
+
+
+
Dane do wysyłki
-
+
+
+
{{form.street.label}}
@@ -72,7 +73,30 @@
{{form.zip_code}}
+
+
+
+
Płatność i wysyłka
+
+
+
+
+
+
+ {{form.payment_method.label}}
+ {{form.payment_method}}
+
+
+
+
+ {{form.delivery_method.label}}
+ {{form.delivery_method}}
+
+
+
+
+
-
{{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ł