Merge branch 'main' into storefront-nuxt

This commit is contained in:
Egor Pavlovich Gorbunov 2025-10-21 12:59:12 +03:00
commit ea53f398a3
23 changed files with 921 additions and 268 deletions

201
README.md
View file

@ -2,153 +2,138 @@
![LOGO](core/docs/images/evibes-big.png) ![LOGO](core/docs/images/evibes-big.png)
eVibes — your store without the extra baggage. eVibes — a lightweight, production-ready ecommerce backend. Storefront, product catalog, cart, and orders work out of the box. Minimal complexity, maximum flexibility — install, adjust to your needs, and start selling.
Everything works out of the box: storefront, product catalog, cart, and orders.
Minimal complexity, maximum flexibility — install, adjust to your needs, and start selling. - Public issues: https://plane.wiseless.xyz/spaces/issues/dd33cb0ab9b04ef08a10f7eefae6d90c/?board=kanban
## Table of Contents ## Table of Contents
- [Features](#features) - Features
- [Getting Started](#getting-started) - Quick Start
- [Prerequisites](#prerequisites) - Prerequisites
- [Installation](#installation) - Installation
- [Configuration](#configuration) - Configuration
- [Dockerfile](#Dockerfile) - Dockerfile
- [nginx](#nginx) - nginx
- [.env](#env) - .env
- [Usage](#usage) - Usage
- [Contact](#contact) - Contributing
- Contact
- License
## Features ## Features
- **Modular Architecture**: Extend and customize the backend to fit your needs. - Modular backend, easy to extend and customize
- **Dockerized Deployment**: Quick setup and deployment using Docker and Docker Compose. - Dockerized deployment with Docker Compose
- **Asynchronous Task Processing**: Integrated Celery workers and beat scheduler for background tasks. - Celery workers and beat for background tasks
- **GraphQL and REST APIs**: Supports both GraphQL and RESTful API endpoints. - REST and GraphQL APIs
- **Internationalization**: Multilingual support using modeltranslate. - Internationalization with modeltranslation
- **Advanced Caching**: Utilizes Redis for caching and task queuing. - Redis-based caching and queues
- **Security**: Implements JWT authentication and rate limiting. - JWT auth and rate limiting
## Getting Started ## Quick Start
### Prerequisites ### Prerequisites
- Docker and Docker Compose are installed on your machine. - Docker and Docker Compose
### Installation ### Installation
1. Clone the repository: 1. Clone the repository
```bash ```bash
git clone https://gitlab.com/wiseless.xyz/eVibes.git git clone https://gitlab.com/wiseless.xyz/eVibes.git
cd eVibes cd eVibes
``` ```
2. Choose the storefront. By default, `main` branch has no storefront included. 2. Choose a storefront (optional). The `main` branch ships without a storefront. If you want one, pick a branch:
Skip this step if you're OK with that and plan to only use API or develop your own storefront.
```bash ```bash
git checkout storefront-<options: nuxt, next, sk, qwik > git checkout storefront-<nuxt|next|sk|qwik>
``` ```
3. Generate your .env file. Check and confirm the contents afterward. 3. Generate your .env file and review its values
- Windows
```powershell
scripts\Windows\generate-environment-file.ps1
```
- Unix
```bash
scripts/Unix/generate-environment-file.sh
```
- Windows 4. Install dependencies
```powershell - Windows
scripts\Windows\generate-environment-file.ps1 ```powershell
``` scripts\Windows\install.ps1
- Unix ```
```bash - Unix
scripts/Unix/generate-environment-file.sh ```bash
``` scripts/Unix/install.sh
```
4. Install all the dependencies. 5. Run the stack
- Windows
```powershell
scripts\Windows\run.ps1
```
- Unix
```bash
scripts/Unix/run.sh
```
- Windows 6. Production checklist
```powershell - Include `nginx.conf` into your Nginx setup
scripts\Windows\install.ps1 - Issue TLS certs with Certbot (https://certbot.eff.org/)
```
- Unix
```bash
scripts/Unix/install.sh
```
5. Spin it up.
- Windows
```powershell
scripts\Windows\run.ps1
```
- Unix
```bash
scripts/Unix/run.sh
```
6. Bring to production.
Include `nginx` file to your nginx configuration, you really want to install and
run [Certbot](https://certbot.eff.org/) afterward!
## Configuration ## Configuration
### Dockerfile ### Dockerfile
If you rely on locale mirrors, adjust Debian sources before running install scripts:
Remember to change the ```
`RUN sed -i 's|https://deb.debian.org/debian|https://ftp.<locale>.debian.org/debian|g' /etc/apt/sources.list.d/debian.sources` RUN sed -i 's|https://deb.debian.org/debian|https://ftp.<locale>.debian.org/debian|g' /etc/apt/sources.list.d/debian.sources
before running installment scripts ```
### nginx ### nginx
- Comment out SSL-related lines
Please comment-out SSL-related lines, then apply necessary configurations, run `certbot --cert-only --nginx`, - Apply your domain-specific settings
decomment previously commented lines, and enjoy eVibes over HTTPS! - Run `certbot --cert-only --nginx`
- Uncomment SSL lines and reload Nginx
### .env ### .env
After generation, review and update secrets and credentials (API keys, DB password, Redis password, etc.).
After .env file generation, you may want to edit some of its values, such as macroservices` API keys, database password,
redis password, etc.
## Usage ## Usage
- Add the necessary subdomains to DNS-settings of your domain, those are: - DNS records youll typically want:
1. @.your-domain.com
2. www.your-domain.com
3. api.your-domain.com
4. b2b.your-domain.com
5. prometheus.your-domain.com
1. @.your-domain.com - For local development, add hosts entries (development only):
2. www.your-domain.com ```hosts
3. api.your-domain.com 127.0.0.1 api.localhost
4. b2b.your-domain.com 127.0.0.1 b2b.localhost
5. prometheus.your-domain.com
- Add these lines to your hosts-file to use django-hosts functionality on localhost(*DEVELOPMENT ONLY*):
```hosts
127.0.0.1 api.localhost
127.0.0.1 b2b.localhost
```
Once the services are up and running, you can access the application at
`http://api.your-domain.com`(http://api.localhost:8000).
- **Django Admin**: `http://api.your-domain.com/` (will redirect to admin)
- **API Docs**:
- REST API: `http://api.localhost:8000/docs/swagger` or `http://api.localhost:8000/docs/redoc`
- GraphQL API: `http://api.localhost:8000/graphql/`
## Uninstall eVibes
You are not planning to do that, aren't you?
- Windows
```powershell
scripts\Windows\uninstall.ps1
```
- Unix
```bash
scripts/Unix/uninstall.sh
``` ```
- Once running, access:
- API root / Admin redirect: http://api.localhost:8000/
- REST docs: http://api.localhost:8000/docs/swagger or http://api.localhost:8000/docs/redoc
- GraphQL: http://api.localhost:8000/graphql/
## Contributing
- Track and report issues here: https://plane.wiseless.xyz/spaces/issues/dd33cb0ab9b04ef08a10f7eefae6d90c/?board=list
- Pull requests are welcome. Please keep changes minimal and focused.
## Contact ## Contact
- **Author**: Egor "fureunoir" Gorbunov - Author: Egor "fureunoir" Gorbunov
- Email: contact@fureunoir.com - Email: contact@fureunoir.com
- Telegram: [@fureunoir](https://t.me/fureunoir) - Telegram: https://t.me/fureunoir
## License
This project is licensed under the terms of the LICENSE file included in this repository.
![FAVICON](core/docs/images/evibes.png) ![FAVICON](core/docs/images/evibes.png)

View file

@ -0,0 +1,21 @@
# Generated by Django 5.2 on 2025-10-21 09:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("blog", "0006_post_meta_description_post_meta_description_ar_ar_and_more"),
]
operations = [
migrations.AddField(
model_name="post",
name="is_static_page",
field=models.BooleanField(
default=False,
help_text="is this a post for a page with static URL (e.g. `/help/delivery`)?",
verbose_name="is static page",
),
),
]

View file

@ -1,4 +1,4 @@
from django.db.models import CASCADE, CharField, FileField, ForeignKey, ManyToManyField from django.db.models import CASCADE, CharField, FileField, ForeignKey, ManyToManyField, BooleanField
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_extensions.db.fields import AutoSlugField from django_extensions.db.fields import AutoSlugField
from markdown.extensions.toc import TocExtension from markdown.extensions.toc import TocExtension
@ -8,26 +8,13 @@ from core.abstract import NiceModel
class Post(NiceModel): # type: ignore [django-manager-missing] class Post(NiceModel): # type: ignore [django-manager-missing]
""" __doc__ = _(
Represents a blog post model extending NiceModel. "Represents a blog post model. "
"The Post class defines the structure and behavior of a blog post. "
The Post class defines the structure and behavior of a blog post. It includes "It includes attributes for author, title, content, optional file attachment, slug, and associated tags. "
attributes for author, title, content, optional file attachment, slug, "The class enforces constraints such as requiring either content or a file attachment but not both simultaneously. "
and associated tags. The class enforces constraints such as requiring either "It also supports automatic slug generation based on the title."
content or a file attachment but not both simultaneously. It also supports )
automatic slug generation based on the title. This model can be used in
a blogging platform to manage posts created by users.
Attributes:
is_publicly_visible (bool): Specifies whether the post is visible to the public.
author (ForeignKey): A reference to the user who authored the post.
title (CharField): The title of the post. Must be unique and non-empty.
content (MarkdownField): The content of the post written in Markdown format.
file (FileField): An optional file attachment for the post.
slug (AutoSlugField): A unique, automatically generated slug based on the title.
tags (ManyToManyField): Tags associated with the post for categorization.
"""
is_publicly_visible = True is_publicly_visible = True
@ -76,6 +63,11 @@ class Post(NiceModel): # type: ignore [django-manager-missing]
slug = AutoSlugField(populate_from="title", allow_unicode=True, unique=True, editable=False) slug = AutoSlugField(populate_from="title", allow_unicode=True, unique=True, editable=False)
tags = ManyToManyField(to="blog.PostTag", blank=True, related_name="posts") tags = ManyToManyField(to="blog.PostTag", blank=True, related_name="posts")
meta_description = CharField(max_length=150, blank=True, null=True) meta_description = CharField(max_length=150, blank=True, null=True)
is_static_page = BooleanField(
default=False,
verbose_name=_("is static page"),
help_text=_("is this a post for a page with static URL (e.g. `/help/delivery`)?"),
)
def __str__(self): def __str__(self):
return f"{self.title} | {self.author.first_name} {self.author.last_name}" return f"{self.title} | {self.author.first_name} {self.author.last_name}"

View file

@ -5,9 +5,9 @@ from blog.viewsets import PostViewSet
app_name = "blog" app_name = "blog"
payment_router = DefaultRouter() blog_router = DefaultRouter()
payment_router.register(prefix=r"posts", viewset=PostViewSet, basename="posts") blog_router.register(prefix=r"posts", viewset=PostViewSet, basename="posts")
urlpatterns = [ urlpatterns = [
path(r"", include(payment_router.urls)), path(r"", include(blog_router.urls)),
] ]

View file

@ -410,6 +410,7 @@ class ProductAdmin(FieldsetsMixin, ActivationActionsMixin, ModelAdmin): # type:
"sku", "sku",
) )
readonly_fields = ( readonly_fields = (
"sku",
"slug", "slug",
"uuid", "uuid",
"modified", "modified",
@ -517,6 +518,7 @@ class VendorAdmin(FieldsetsMixin, ActivationActionsMixin, ModelAdmin): # type:
"users", "users",
] ]
additional_fields = [ additional_fields = [
"integration_path",
"last_processing_response", "last_processing_response",
"b2b_auth_token", "b2b_auth_token",
] ]

View file

@ -324,7 +324,7 @@ class ProductFilter(FilterSet): # type: ignore [misc]
if "?" in mapped_requested: if "?" in mapped_requested:
final_ordering = ["personal_order_tail", "?"] final_ordering = ["personal_order_tail", "?"]
else: else:
final_ordering = mapped_requested + ["personal_order_tail"] final_ordering = ["personal_order_tail"] + mapped_requested
if final_ordering: if final_ordering:
qs = qs.order_by(*final_ordering) qs = qs.order_by(*final_ordering)

View file

@ -5,7 +5,6 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("core", "0044_vendor_last_processing_response"), ("core", "0044_vendor_last_processing_response"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
@ -332,27 +331,19 @@ class Migration(migrations.Migration):
), ),
migrations.AddIndex( migrations.AddIndex(
model_name="order", model_name="order",
index=models.Index( index=models.Index(fields=["user", "status"], name="core_order_user_id_4407f8_idx"),
fields=["user", "status"], name="core_order_user_id_4407f8_idx"
),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name="order", model_name="order",
index=models.Index( index=models.Index(fields=["status", "buy_time"], name="core_order_status_4a088a_idx"),
fields=["status", "buy_time"], name="core_order_status_4a088a_idx"
),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name="orderproduct", model_name="orderproduct",
index=models.Index( index=models.Index(fields=["order", "status"], name="core_orderp_order_i_d16192_idx"),
fields=["order", "status"], name="core_orderp_order_i_d16192_idx"
),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name="orderproduct", model_name="orderproduct",
index=models.Index( index=models.Index(fields=["product", "status"], name="core_orderp_product_ee8abb_idx"),
fields=["product", "status"], name="core_orderp_product_ee8abb_idx"
),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name="product", model_name="product",

View file

@ -0,0 +1,301 @@
# Generated by Django 5.2 on 2025-10-18 19:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0045_alter_product_name_alter_product_name_ar_ar_and_more"),
]
operations = [
migrations.AlterField(
model_name="attribute",
name="name",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_ar_ar",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_cs_cz",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_da_dk",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_de_de",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_en_gb",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_en_us",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_es_es",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_fa_ir",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_fr_fr",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_he_il",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_hi_in",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_hr_hr",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_id_id",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_it_it",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_ja_jp",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_kk_kz",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_ko_kr",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_nl_nl",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_no_no",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_pl_pl",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_pt_br",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_ro_ro",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_ru_ru",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_sv_se",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_th_th",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_tr_tr",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_vi_vn",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
migrations.AlterField(
model_name="attribute",
name="name_zh_hans",
field=models.CharField(
help_text="name of this attribute",
max_length=255,
null=True,
verbose_name="attribute's name",
),
),
]

View file

@ -0,0 +1,16 @@
# Generated by Django 5.2 on 2025-10-18 21:16
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0046_alter_attribute_name_alter_attribute_name_ar_ar_and_more"),
]
operations = [
migrations.AlterUniqueTogether(
name="attribute",
unique_together={("name", "group", "value_type")},
),
]

View file

@ -0,0 +1,32 @@
# Generated by Django 5.2 on 2025-10-21 09:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0047_alter_attribute_unique_together"),
]
operations = [
migrations.AddField(
model_name="vendor",
name="integration_path",
field=models.CharField(
blank=True,
help_text="vendor's integration file path",
max_length=255,
null=True,
verbose_name="integration path",
),
),
migrations.AlterField(
model_name="productimage",
name="priority",
field=models.PositiveIntegerField(
default=1,
help_text="determines the order in which images are displayed",
verbose_name="display priority",
),
),
]

View file

@ -146,6 +146,13 @@ class Vendor(ExportModelOperationsMixin("vendor"), NiceModel): # type: ignore [
verbose_name=_("response file"), verbose_name=_("response file"),
help_text=_("vendor's last processing response"), help_text=_("vendor's last processing response"),
) )
integration_path = CharField(
null=True,
blank=True,
max_length=255,
help_text=_("vendor's integration file path"),
verbose_name=_("integration path"),
)
def __str__(self) -> str: def __str__(self) -> str:
return self.name return self.name
@ -652,7 +659,6 @@ class Attribute(ExportModelOperationsMixin("attribute"), NiceModel): # type: ig
max_length=255, max_length=255,
help_text=_("name of this attribute"), help_text=_("name of this attribute"),
verbose_name=_("attribute's name"), verbose_name=_("attribute's name"),
unique=True,
) )
is_filterable = BooleanField( is_filterable = BooleanField(
@ -668,6 +674,7 @@ class Attribute(ExportModelOperationsMixin("attribute"), NiceModel): # type: ig
unique_together = ( unique_together = (
"name", "name",
"group", "group",
"value_type",
) )
verbose_name = _("attribute") verbose_name = _("attribute")
verbose_name_plural = _("attributes") verbose_name_plural = _("attributes")
@ -732,9 +739,8 @@ class ProductImage(ExportModelOperationsMixin("product_image"), NiceModel): # t
verbose_name=_("product image"), verbose_name=_("product image"),
upload_to=get_product_uuid_as_path, upload_to=get_product_uuid_as_path,
) )
priority = IntegerField( priority = PositiveIntegerField(
default=1, default=1,
validators=[MinValueValidator(1)],
help_text=_("determines the order in which images are displayed"), help_text=_("determines the order in which images are displayed"),
verbose_name=_("display priority"), verbose_name=_("display priority"),
) )

View file

@ -3,7 +3,6 @@ from collections import defaultdict
from contextlib import suppress from contextlib import suppress
from typing import Collection, Any from typing import Collection, Any
from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache from django.core.cache import cache
from django.db.models.functions import Length from django.db.models.functions import Length
from rest_framework.fields import JSONField, SerializerMethodField from rest_framework.fields import JSONField, SerializerMethodField
@ -76,16 +75,10 @@ class CategoryDetailSerializer(ModelSerializer):
def get_filterable_attributes(self, obj: Category) -> list[dict]: def get_filterable_attributes(self, obj: Category) -> list[dict]:
cache_key = f"{obj.uuid}_filterable_results" cache_key = f"{obj.uuid}_filterable_results"
filterable_results = cache.get(cache_key) filterable_results = cache.get(cache_key)
if filterable_results: if filterable_results is not None:
return filterable_results return filterable_results
request = self.context.get("request") attrs_qs = obj.attributes.filter(is_active=True, is_filterable=True)
user = getattr(request, "user", AnonymousUser())
attrs_qs = (
obj.attributes.filter(is_filterable=True)
if user.has_perm("view_attribute")
else obj.attributes.filter(is_active=True, is_filterable=True)
)
attributes = list(attrs_qs) attributes = list(attrs_qs)
attr_ids = [a.id for a in attributes] attr_ids = [a.id for a in attributes]
@ -116,8 +109,7 @@ class CategoryDetailSerializer(ModelSerializer):
} }
) )
if not user.has_perm("view_attribute"): cache.set(cache_key, filterable_results, 3600)
cache.set(cache_key, filterable_results, 86400)
return filterable_results return filterable_results

View file

@ -2,51 +2,54 @@ from django.conf import settings
from django.contrib.sitemaps import Sitemap from django.contrib.sitemaps import Sitemap
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from blog.models import Post
from core.models import Brand, Category, Product from core.models import Brand, Category, Product
from core.utils.seo_builders import any_non_digital
from evibes.settings import LANGUAGE_CODE
class StaticPagesSitemap(Sitemap): # type: ignore [type-arg] class SitemapLanguageMixin:
def _lang(self) -> str:
req = getattr(self, "request", None)
return getattr(req, "LANGUAGE_CODE", settings.LANGUAGE_CODE)
class StaticPagesSitemap(SitemapLanguageMixin, Sitemap): # type: ignore [type-arg]
protocol = "https" protocol = "https"
changefreq = "monthly" changefreq = "monthly"
priority = 0.8 priority = 0.8
limit = 1000 limit = 1000
PAGES = [
{
"name": _("Home"),
"path": f"/{LANGUAGE_CODE}",
"lastmod": settings.RELEASE_DATE,
},
{
"name": _("Contact Us"),
"path": f"/{LANGUAGE_CODE}/contact-us",
"lastmod": settings.RELEASE_DATE,
},
{
"name": _("About Us"),
"path": f"/{LANGUAGE_CODE}/about-us",
"lastmod": settings.RELEASE_DATE,
},
{
"name": _("Payment Information"),
"path": f"/{LANGUAGE_CODE}/help/payments",
"lastmod": settings.RELEASE_DATE,
},
]
if any_non_digital():
PAGES.append(
{
"name": _("Delivery"),
"path": f"/{LANGUAGE_CODE}/help/delivery",
"lastmod": settings.RELEASE_DATE,
}
)
def items(self): def items(self):
return self.PAGES lang = self._lang()
pages = [
{
"name": _("Home"),
"path": f"/{lang}",
"lastmod": settings.RELEASE_DATE,
},
{
"name": _("Contact Us"),
"path": f"/{lang}/contact-us",
"lastmod": settings.RELEASE_DATE,
},
{
"name": _("About Us"),
"path": f"/{lang}/about-us",
"lastmod": settings.RELEASE_DATE,
},
]
for static_post_page in Post.objects.filter(is_static_page=True, is_active=True).only(
"title", "slug", "modified"
):
pages.append(
{
"name": static_post_page.title,
"path": f"/{lang}/information/{static_post_page.slug}",
"lastmod": static_post_page.modified,
}
)
return pages
def location(self, obj): def location(self, obj):
return obj["path"] return obj["path"]
@ -55,33 +58,7 @@ class StaticPagesSitemap(Sitemap): # type: ignore [type-arg]
return obj.get("lastmod") return obj.get("lastmod")
# class FeaturedProductsSitemap(Sitemap): # type: ignore [type-arg] class ProductSitemap(SitemapLanguageMixin, Sitemap): # type: ignore [type-arg]
# protocol = "https"
# changefreq = "daily"
# priority = 0.9
# limit = 25000
#
# def items(self):
# return (
# Product.objects.filter(
# is_active=True,
# brand__is_active=True,
# category__is_active=True,
# stocks__isnull=False,
# stocks__vendor__is_active=True,
# )
# .only("uuid", "name", "modified", "slug")
# .order_by("-modified")
# )
#
# def lastmod(self, obj):
# return obj.modified
#
# def location(self, obj):
# return f"/{LANGUAGE_CODE}/product/{obj.slug}"
class ProductSitemap(Sitemap): # type: ignore [type-arg]
protocol = "https" protocol = "https"
changefreq = "daily" changefreq = "daily"
priority = 0.9 priority = 0.9
@ -104,10 +81,10 @@ class ProductSitemap(Sitemap): # type: ignore [type-arg]
return obj.modified return obj.modified
def location(self, obj): def location(self, obj):
return f"/{LANGUAGE_CODE}/product/{obj.slug}" return f"/{self._lang()}/product/{obj.slug if obj.slug else '404-non-existent-product'}"
class CategorySitemap(Sitemap): # type: ignore [type-arg] class CategorySitemap(SitemapLanguageMixin, Sitemap): # type: ignore [type-arg]
protocol = "https" protocol = "https"
changefreq = "weekly" changefreq = "weekly"
priority = 0.7 priority = 0.7
@ -120,10 +97,10 @@ class CategorySitemap(Sitemap): # type: ignore [type-arg]
return obj.modified return obj.modified
def location(self, obj): def location(self, obj):
return f"/{LANGUAGE_CODE}/catalog/{obj.slug}" return f"/{self._lang()}/catalog/{obj.slug if obj.slug else '404-non-existent-category'}"
class BrandSitemap(Sitemap): # type: ignore [type-arg] class BrandSitemap(SitemapLanguageMixin, Sitemap): # type: ignore [type-arg]
protocol = "https" protocol = "https"
changefreq = "weekly" changefreq = "weekly"
priority = 0.6 priority = 0.6
@ -136,4 +113,4 @@ class BrandSitemap(Sitemap): # type: ignore [type-arg]
return obj.modified return obj.modified
def location(self, obj): def location(self, obj):
return f"/{LANGUAGE_CODE}/brand/{obj.slug}" return f"/{self._lang()}/brand/{obj.slug if obj.slug else '404-non-existent-brand'}"

View file

@ -4,7 +4,7 @@ import shutil
import uuid import uuid
from datetime import date, timedelta from datetime import date, timedelta
from time import sleep from time import sleep
from typing import Any from typing import Any, Type
import requests import requests
from celery.app import shared_task from celery.app import shared_task
@ -14,7 +14,8 @@ from django.core.cache import cache
from core.models import Product, Promotion from core.models import Product, Promotion
from core.utils.caching import set_default_cache from core.utils.caching import set_default_cache
from core.vendors import VendorInactiveError, delete_stale from core.utils.vendors import get_vendors_integrations
from core.vendors import VendorInactiveError, delete_stale, AbstractVendor
from evibes.settings import MEDIA_ROOT from evibes.settings import MEDIA_ROOT
logger = get_task_logger(__name__) logger = get_task_logger(__name__)
@ -39,16 +40,15 @@ def update_products_task() -> tuple[bool, str]:
if not update_products_task_running: if not update_products_task_running:
cache.set("update_products_task_running", True, 86400) cache.set("update_products_task_running", True, 86400)
vendors_classes: list[Any] = [] vendors: list[Type[AbstractVendor]] = get_vendors_integrations()
for vendor_class in vendors_classes: for vendor in vendors:
vendor = vendor_class()
try: try:
vendor.update_stock() vendor.update_stock()
except VendorInactiveError: except VendorInactiveError:
logger.info(f"Skipping {vendor_class} due to inactivity") logger.info(f"Skipping {vendor.__str__} due to inactivity")
except Exception as e: except Exception as e:
logger.warning(f"Skipping {vendor_class} due to error: {e!s}") logger.warning(f"Skipping {vendor.__str__} due to error: {e!s}")
delete_stale() delete_stale()
@ -70,10 +70,9 @@ def update_orderproducts_task() -> tuple[bool, str]:
message confirming the successful execution of the task. message confirming the successful execution of the task.
:rtype: Tuple[bool, str] :rtype: Tuple[bool, str]
""" """
vendors_classes: list[Any] = [] vendors: list[Type[AbstractVendor]] = get_vendors_integrations()
for vendor_class in vendors_classes: for vendor in vendors:
vendor = vendor_class()
vendor.update_order_products_statuses() vendor.update_order_products_statuses()
return True, "Success" return True, "Success"

View file

@ -1,16 +1,39 @@
import logging
from contextlib import suppress
from typing import Type from typing import Type
from celery import current_task
from celery.utils.log import get_task_logger
from core.models import Vendor from core.models import Vendor
from core.vendors import AbstractVendor from core.vendors import AbstractVendor
from evibes.utils.misc import create_object from evibes.utils.misc import create_object
sync_logger = logging.getLogger("django")
async_logger = get_task_logger(__name__)
def _in_celery_task() -> bool:
task = current_task
with suppress(Exception):
return bool(task and getattr(task, "request", None) and getattr(task.request, "id", None))
return False
def get_vendors_integrations(name: str | None = None) -> list[Type[AbstractVendor]]: def get_vendors_integrations(name: str | None = None) -> list[Type[AbstractVendor]]:
vendors_integrations: list[Type[AbstractVendor]] = [] vendors_integrations: list[Type[AbstractVendor]] = []
vendors = Vendor.objects.filter(is_active=True, name=name) if name else Vendor.objects.filter(is_active=True)
vendors = Vendor.objects.filter(is_active=True, integration_path__isnull=False)
if name:
vendors = vendors.filter(name=name)
logger = async_logger if _in_celery_task() else sync_logger
for vendor in vendors: for vendor in vendors:
if vendor.integration_path: try:
module_name = ".".join(vendor.integration_path.split(".")[:-1]) module_name, class_name = vendor.integration_path.rsplit(".", 1)
class_name = vendor.integration_path.split(".")[-1]
vendors_integrations.append(create_object(module_name, class_name)) vendors_integrations.append(create_object(module_name, class_name))
except Exception as e:
logger.warning("Couldn't load integration for vendor %s: %s", vendor.name, e)
return vendors_integrations return vendors_integrations

View file

@ -1,5 +1,6 @@
import gzip import gzip
import json import json
import time
from contextlib import suppress from contextlib import suppress
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
@ -7,11 +8,13 @@ from io import BytesIO
from math import ceil, log10 from math import ceil, log10
from typing import Any from typing import Any
from celery.utils.log import get_task_logger
from constance import config from constance import config
from django.conf import settings from django.conf import settings
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.db.models import QuerySet from django.db.models import QuerySet
from django.db.utils import OperationalError
from core.elasticsearch import process_system_query from core.elasticsearch import process_system_query
from core.models import ( from core.models import (
@ -28,6 +31,8 @@ from core.models import (
from payments.errors import RatesError from payments.errors import RatesError
from payments.utils import get_rates from payments.utils import get_rates
async_logger = get_task_logger(__name__)
class NotEnoughBalanceError(Exception): class NotEnoughBalanceError(Exception):
""" """
@ -416,9 +421,11 @@ class AbstractVendor:
def process_attribute(self, key: str, value: Any, product: Product, attr_group: AttributeGroup) -> None: def process_attribute(self, key: str, value: Any, product: Product, attr_group: AttributeGroup) -> None:
if not value: if not value:
async_logger.warning(f"No value for attribute {key!r} at {product.name!r}...")
return return
if not attr_group: if not attr_group:
async_logger.warning(f"No group for attribute {key!r} at {product.name!r}...")
return return
if key in self.blocked_attributes: if key in self.blocked_attributes:
@ -440,14 +447,27 @@ class AbstractVendor:
) )
except Attribute.MultipleObjectsReturned: except Attribute.MultipleObjectsReturned:
attribute = Attribute.objects.filter(name=key, group=attr_group).order_by("uuid").first() # type: ignore [assignment] attribute = Attribute.objects.filter(name=key, group=attr_group).order_by("uuid").first() # type: ignore [assignment]
attribute.is_active = True fields_to_update: list[str] = []
attribute.value_type = attr_value_type if not attribute.is_active:
attribute.save() attribute.is_active = True
fields_to_update.append("is_active")
if attribute.value_type != attr_value_type:
attribute.value_type = attr_value_type
fields_to_update.append("value_type")
if fields_to_update:
for attempt in range(5):
try:
attribute.save(update_fields=fields_to_update)
break
except OperationalError as e:
if "deadlock detected" in str(e):
time.sleep(0.1 * (2**attempt))
continue
raise
except IntegrityError: except IntegrityError:
async_logger.warning(f"IntegrityError while processing attribute {key!r}...")
return return
attribute.save()
if not is_created: if not is_created:
return return

View file

@ -222,28 +222,64 @@ LANGUAGES: tuple[tuple[str, str], ...] = (
LANGUAGE_CODE: str = "en-gb" LANGUAGE_CODE: str = "en-gb"
CURRENCIES: tuple[tuple[str, str], ...] = ( CURRENCIES_BY_LANGUAGES: tuple[tuple[str, str], ...] = (
("en-gb", "EUR"),
("ar-ar", "AED"), ("ar-ar", "AED"),
("cs-cz", "CZK"), ("cs-cz", "CZK"),
("da-dk", "EUR"), ("da-dk", "DKK"),
("de-de", "EUR"), ("de-de", "EUR"),
("en-gb", "GBP"),
("en-us", "USD"), ("en-us", "USD"),
("es-es", "EUR"), ("es-es", "EUR"),
("fa-ir", "IRR"),
("fr-fr", "EUR"), ("fr-fr", "EUR"),
("he-il", "ILS"),
("hi-in", "INR"), ("hi-in", "INR"),
("hr-hr", "EUR"),
("id-id", "IDR"),
("it-it", "EUR"), ("it-it", "EUR"),
("ja-jp", "JPY"), ("ja-jp", "JPY"),
("kk-kz", "KZT"), ("kk-kz", "KZT"),
("ko-kr", "KRW"),
("nl-nl", "EUR"), ("nl-nl", "EUR"),
("no-no", "NOK"),
("pl-pl", "PLN"), ("pl-pl", "PLN"),
("pt-br", "EUR"), ("pt-br", "BRL"),
("ro-ro", "RON"), ("ro-ro", "RON"),
("ru-ru", "RUB"), ("ru-ru", "RUB"),
("sv-se", "SEK"),
("th-th", "THB"),
("tr-tr", "TRY"),
("vi-vn", "VND"),
("zh-hans", "CNY"), ("zh-hans", "CNY"),
) )
CURRENCY_CODE: str = dict(CURRENCIES).get(LANGUAGE_CODE) # type: ignore [assignment] CURRENCIES_WITH_SYMBOLS: tuple[tuple[str, str], ...] = (
("AED", "د.إ"),
("BRL", "R$"),
("CNY", "¥"),
("CZK", ""),
("DKK", "kr"),
("EUR", ""),
("GBP", "£"),
("IDR", "Rp"),
("ILS", ""),
("INR", ""),
("IRR", ""),
("JPY", "¥"),
("KRW", ""),
("KZT", ""),
("NOK", "kr"),
("PLN", ""),
("RON", "lei"),
("RUB", ""),
("SEK", "kr"),
("THB", "฿"),
("TRY", ""),
("USD", "$"),
("VND", ""),
)
CURRENCY_CODE: str = dict(CURRENCIES_BY_LANGUAGES).get(LANGUAGE_CODE) # type: ignore[assignment]
MODELTRANSLATION_FALLBACK_LANGUAGES: tuple[str, ...] = (LANGUAGE_CODE, "en-us", "de-de") MODELTRANSLATION_FALLBACK_LANGUAGES: tuple[str, ...] = (LANGUAGE_CODE, "en-us", "de-de")

View file

@ -5,8 +5,8 @@ from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.admin import ActivationActionsMixin from core.admin import ActivationActionsMixin
from payments.forms import TransactionForm from payments.forms import GatewayForm, TransactionForm
from payments.models import Balance, Transaction from payments.models import Balance, Transaction, Gateway
class TransactionInline(admin.TabularInline): # type: ignore [type-arg] class TransactionInline(admin.TabularInline): # type: ignore [type-arg]
@ -41,3 +41,18 @@ class TransactionAdmin(ActivationActionsMixin, ModelAdmin): # type: ignore [mis
list_filter = ("currency", "payment_method") list_filter = ("currency", "payment_method")
ordering = ("balance",) ordering = ("balance",)
form = TransactionForm form = TransactionForm
@register(Gateway)
class GatewayAdmin(ActivationActionsMixin, ModelAdmin):
list_display = (
"name",
"can_be_used",
"is_active",
)
search_fields = (
"name",
"default_currency",
)
ordering = ("name",)
form = GatewayForm

View file

@ -1,7 +1,7 @@
from django import forms from django import forms
from core.widgets import JSONTableWidget from core.widgets import JSONTableWidget
from payments.models import Transaction from payments.models import Gateway, Transaction
class TransactionForm(forms.ModelForm): # type: ignore [type-arg] class TransactionForm(forms.ModelForm): # type: ignore [type-arg]
@ -11,3 +11,12 @@ class TransactionForm(forms.ModelForm): # type: ignore [type-arg]
widgets = { widgets = {
"process": JSONTableWidget(), "process": JSONTableWidget(),
} }
class GatewayForm(forms.ModelForm): # type: ignore [type-arg]
class Meta:
model = Gateway
fields = "__all__"
widgets = {
"integration_variables": JSONTableWidget(),
}

View file

@ -1,4 +1,4 @@
from payments.models import Transaction from typing import Any
class UnknownGatewayError(Exception): class UnknownGatewayError(Exception):
@ -7,9 +7,9 @@ class UnknownGatewayError(Exception):
class AbstractGateway: class AbstractGateway:
@staticmethod @staticmethod
def process_transaction(transaction: Transaction) -> None: def process_transaction(transaction: Any) -> None:
raise NotImplementedError raise NotImplementedError
@staticmethod @staticmethod
def process_callback(transaction: Transaction) -> None: def process_callback(transaction: Any) -> None:
raise NotImplementedError raise NotImplementedError

View file

@ -0,0 +1,147 @@
# Generated by Django 5.2 on 2025-10-21 09:24
import django.db.models.deletion
import django_extensions.db.fields
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("payments", "0004_alter_transaction_payment_method"),
]
operations = [
migrations.CreateModel(
name="Gateway",
fields=[
(
"uuid",
models.UUIDField(
default=uuid.uuid4,
editable=False,
help_text="unique id is used to surely identify any database object",
primary_key=True,
serialize=False,
verbose_name="unique id",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="if set to false, this object can't be seen by users without needed permission",
verbose_name="is active",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True,
help_text="when the object first appeared on the database",
verbose_name="created",
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True,
help_text="when the object was last modified",
verbose_name="modified",
),
),
("name", models.CharField(max_length=20, verbose_name="name")),
(
"default_currency",
models.CharField(
choices=[
("AED", "د.إ"),
("BRL", "R$"),
("CNY", "¥"),
("CZK", ""),
("DKK", "kr"),
("EUR", ""),
("GBP", "£"),
("IDR", "Rp"),
("ILS", ""),
("INR", ""),
("IRR", ""),
("JPY", "¥"),
("KRW", ""),
("KZT", ""),
("NOK", "kr"),
("PLN", ""),
("RON", "lei"),
("RUB", ""),
("SEK", "kr"),
("THB", "฿"),
("TRY", ""),
("USD", "$"),
("VND", ""),
],
max_length=4,
verbose_name="default currency",
),
),
(
"currencies",
models.CharField(
help_text="comma separated list of currencies supported by this gateway, choose from AED, BRL, CNY, CZK, DKK, EUR, GBP, IDR, ILS, INR, IRR, JPY, KRW, KZT, NOK, PLN, RON, RUB, SEK, THB, TRY, USD, VND",
max_length=255,
verbose_name="currencies",
),
),
(
"integration_path",
models.CharField(blank=True, max_length=255, null=True),
),
(
"minimum_transaction_amount",
models.FloatField(default=0, verbose_name="minimum transaction amount"),
),
(
"maximum_transaction_amount",
models.FloatField(default=0, verbose_name="maximum transaction amount"),
),
(
"daily_limit",
models.PositiveIntegerField(
default=0,
help_text="daily sum limit of transactions' amounts. 0 means no limit",
verbose_name="daily limit",
),
),
(
"monthly_limit",
models.PositiveIntegerField(
default=0,
help_text="monthly sum limit of transactions' amounts. 0 means no limit",
verbose_name="monthly limit",
),
),
(
"priority",
models.PositiveIntegerField(default=10, unique=True, verbose_name="priority"),
),
(
"integration_variables",
models.JSONField(default=dict, verbose_name="integration variables"),
),
],
options={
"verbose_name": "payment gateway",
"verbose_name_plural": "payment gateways",
},
),
migrations.AddField(
model_name="transaction",
name="gateway",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="transactions",
to="payments.gateway",
),
),
]

View file

@ -1,9 +1,25 @@
from typing import Type
from constance import config from constance import config
from django.conf import settings
from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.indexes import GinIndex
from django.db.models import CASCADE, CharField, FloatField, ForeignKey, JSONField, OneToOneField, QuerySet from django.db.models import (
CASCADE,
CharField,
FloatField,
ForeignKey,
JSONField,
OneToOneField,
PositiveIntegerField,
QuerySet,
Sum,
)
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.abstract import NiceModel from core.abstract import NiceModel
from evibes.utils.misc import create_object
from payments.gateways import AbstractGateway
class Transaction(NiceModel): class Transaction(NiceModel):
@ -20,6 +36,7 @@ class Transaction(NiceModel):
related_name="payments_transactions", related_name="payments_transactions",
) )
process = JSONField(verbose_name=_("processing details"), default=dict) process = JSONField(verbose_name=_("processing details"), default=dict)
gateway = ForeignKey("payments.Gateway", on_delete=CASCADE, blank=True, null=True, related_name="transactions")
def __str__(self): def __str__(self):
return ( return (
@ -67,3 +84,81 @@ class Balance(NiceModel):
if self.amount != 0.0 and len(str(self.amount).split(".")[1]) > 2: if self.amount != 0.0 and len(str(self.amount).split(".")[1]) > 2:
self.amount = round(self.amount, 2) self.amount = round(self.amount, 2)
super().save(**kwargs) super().save(**kwargs)
class Gateway(NiceModel):
name = CharField(max_length=20, null=False, blank=False, verbose_name=_("name"))
default_currency = CharField(
max_length=4,
null=False,
blank=False,
verbose_name=_("default currency"),
choices=settings.CURRENCIES_WITH_SYMBOLS,
)
currencies = CharField(
max_length=255,
null=False,
blank=False,
verbose_name=_("currencies"),
help_text=_(
f"comma separated list of currencies supported by this gateway, "
f"choose from {', '.join([code for code, _ in settings.CURRENCIES_WITH_SYMBOLS])}"
),
)
integration_path = CharField(max_length=255, null=True, blank=True)
minimum_transaction_amount = FloatField(
null=False, blank=False, default=0, verbose_name=_("minimum transaction amount")
)
maximum_transaction_amount = FloatField(
null=False, blank=False, default=0, verbose_name=_("maximum transaction amount")
)
daily_limit = PositiveIntegerField(
null=False,
blank=False,
default=0,
verbose_name=_("daily limit"),
help_text=_("daily sum limit of transactions' amounts. 0 means no limit"),
)
monthly_limit = PositiveIntegerField(
null=False,
blank=False,
default=0,
verbose_name=_("monthly limit"),
help_text=_("monthly sum limit of transactions' amounts. 0 means no limit"),
)
priority = PositiveIntegerField(null=False, blank=False, default=10, verbose_name=_("priority"), unique=True)
integration_variables = JSONField(null=False, blank=False, default=dict, verbose_name=_("integration variables"))
def __str__(self):
return self.name
class Meta:
verbose_name = _("payment gateway")
verbose_name_plural = _("payment gateways")
@property
def can_be_used(self) -> bool:
today = now().date()
current_month_start = today.replace(day=1)
daily_sum = self.transactions.filter(created__date=today).aggregate(total=Sum("amount"))["total"] or 0
monthly_sum = (
self.transactions.filter(created__gte=current_month_start).aggregate(total=Sum("amount"))["total"] or 0
)
daily_ok = self.daily_limit == 0 or daily_sum < self.daily_limit
monthly_ok = self.monthly_limit == 0 or monthly_sum < self.monthly_limit
return daily_ok and monthly_ok
def get_integration_class_object(self, raise_exc: bool = True) -> Type[AbstractGateway] | None:
if not self.integration_path:
if raise_exc:
raise ValueError(_("gateway integration path is not set"))
return None
try:
module_name, class_name = self.integration_path.rsplit(".", 1)
except ValueError as exc:
raise ValueError(_("invalid integration path: %(path)s") % {"path": self.integration_path}) from exc
return create_object(module_name, class_name)

View file

@ -5,7 +5,6 @@ from typing import Any
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from payments.gateways import AbstractGateway
from payments.models import Balance, Transaction from payments.models import Balance, Transaction
from payments.utils.emailing import balance_deposit_email from payments.utils.emailing import balance_deposit_email
from vibes_auth.models import User from vibes_auth.models import User
@ -24,15 +23,10 @@ def create_balance_on_user_creation_signal(instance: User, created: bool, **kwar
@receiver(post_save, sender=Transaction) @receiver(post_save, sender=Transaction)
def process_transaction_changes(instance: Transaction, created: bool, **kwargs: dict[Any, Any]) -> None: def process_transaction_changes(instance: Transaction, created: bool, **kwargs: dict[Any, Any]) -> None:
if created: if created:
if not instance.gateway:
raise ValueError("gateway is required to process a transaction")
try: try:
gateway = None gateway = instance.gateway.get_integration_class_object()
match instance.process.get("gateway", "default"):
case "gateway":
gateway = AbstractGateway()
case "default":
gateway = AbstractGateway()
case _:
gateway = AbstractGateway()
gateway.process_transaction(instance) gateway.process_transaction(instance)
except Exception as e: except Exception as e:
instance.process = {"status": "ERRORED", "error": str(e)} instance.process = {"status": "ERRORED", "error": str(e)}