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

129
README.md
View file

@ -2,57 +2,55 @@
![LOGO](core/docs/images/evibes-big.png)
eVibes — your store without the extra baggage.
Everything works out of the box: storefront, product catalog, cart, and orders.
Minimal complexity, maximum flexibility — install, adjust to your needs, and start selling.
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.
- Public issues: https://plane.wiseless.xyz/spaces/issues/dd33cb0ab9b04ef08a10f7eefae6d90c/?board=kanban
## Table of Contents
- [Features](#features)
- [Getting Started](#getting-started)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Configuration](#configuration)
- [Dockerfile](#Dockerfile)
- [nginx](#nginx)
- [.env](#env)
- [Usage](#usage)
- [Contact](#contact)
- Features
- Quick Start
- Prerequisites
- Installation
- Configuration
- Dockerfile
- nginx
- .env
- Usage
- Contributing
- Contact
- License
## Features
- **Modular Architecture**: Extend and customize the backend to fit your needs.
- **Dockerized Deployment**: Quick setup and deployment using Docker and Docker Compose.
- **Asynchronous Task Processing**: Integrated Celery workers and beat scheduler for background tasks.
- **GraphQL and REST APIs**: Supports both GraphQL and RESTful API endpoints.
- **Internationalization**: Multilingual support using modeltranslate.
- **Advanced Caching**: Utilizes Redis for caching and task queuing.
- **Security**: Implements JWT authentication and rate limiting.
- Modular backend, easy to extend and customize
- Dockerized deployment with Docker Compose
- Celery workers and beat for background tasks
- REST and GraphQL APIs
- Internationalization with modeltranslation
- Redis-based caching and queues
- JWT auth and rate limiting
## Getting Started
## Quick Start
### Prerequisites
- Docker and Docker Compose are installed on your machine.
- Docker and Docker Compose
### Installation
1. Clone the repository:
1. Clone the repository
```bash
git clone https://gitlab.com/wiseless.xyz/eVibes.git
cd eVibes
```
2. Choose the storefront. By default, `main` branch has no storefront included.
Skip this step if you're OK with that and plan to only use API or develop your own storefront.
2. Choose a storefront (optional). The `main` branch ships without a storefront. If you want one, pick a branch:
```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
@ -62,8 +60,7 @@ Skip this step if you're OK with that and plan to only use API or develop your o
scripts/Unix/generate-environment-file.sh
```
4. Install all the dependencies.
4. Install dependencies
- Windows
```powershell
scripts\Windows\install.ps1
@ -73,8 +70,7 @@ Skip this step if you're OK with that and plan to only use API or develop your o
scripts/Unix/install.sh
```
5. Spin it up.
5. Run the stack
- Windows
```powershell
scripts\Windows\run.ps1
@ -84,71 +80,60 @@ Skip this step if you're OK with that and plan to only use API or develop your o
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!
6. Production checklist
- Include `nginx.conf` into your Nginx setup
- Issue TLS certs with Certbot (https://certbot.eff.org/)
## Configuration
### Dockerfile
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`
before running installment scripts
If you rely on locale mirrors, adjust Debian sources before running install scripts:
```
RUN sed -i 's|https://deb.debian.org/debian|https://ftp.<locale>.debian.org/debian|g' /etc/apt/sources.list.d/debian.sources
```
### nginx
Please comment-out SSL-related lines, then apply necessary configurations, run `certbot --cert-only --nginx`,
decomment previously commented lines, and enjoy eVibes over HTTPS!
- Comment out SSL-related lines
- Apply your domain-specific settings
- Run `certbot --cert-only --nginx`
- Uncomment SSL lines and reload Nginx
### .env
After .env file generation, you may want to edit some of its values, such as macroservices` API keys, database password,
redis password, etc.
After generation, review and update secrets and credentials (API keys, DB password, Redis password, etc.).
## 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
- Add these lines to your hosts-file to use django-hosts functionality on localhost(*DEVELOPMENT ONLY*):
- For local development, add hosts entries (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).
- 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/
- **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/`
## Contributing
## Uninstall eVibes
You are not planning to do that, aren't you?
- Windows
```powershell
scripts\Windows\uninstall.ps1
```
- Unix
```bash
scripts/Unix/uninstall.sh
```
- 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
- **Author**: Egor "fureunoir" Gorbunov
- Author: Egor "fureunoir" Gorbunov
- 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)

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_extensions.db.fields import AutoSlugField
from markdown.extensions.toc import TocExtension
@ -8,26 +8,13 @@ from core.abstract import NiceModel
class Post(NiceModel): # type: ignore [django-manager-missing]
"""
Represents a blog post model extending NiceModel.
The Post class defines the structure and behavior of a blog post. It includes
attributes for author, title, content, optional file attachment, slug,
and associated tags. The class enforces constraints such as requiring either
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.
"""
__doc__ = _(
"Represents a blog post model. "
"The Post class defines the structure and behavior of a blog post. "
"It includes attributes for author, title, content, optional file attachment, slug, and associated tags. "
"The class enforces constraints such as requiring either content or a file attachment but not both simultaneously. "
"It also supports automatic slug generation based on the title."
)
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)
tags = ManyToManyField(to="blog.PostTag", blank=True, related_name="posts")
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):
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"
payment_router = DefaultRouter()
payment_router.register(prefix=r"posts", viewset=PostViewSet, basename="posts")
blog_router = DefaultRouter()
blog_router.register(prefix=r"posts", viewset=PostViewSet, basename="posts")
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",
)
readonly_fields = (
"sku",
"slug",
"uuid",
"modified",
@ -517,6 +518,7 @@ class VendorAdmin(FieldsetsMixin, ActivationActionsMixin, ModelAdmin): # type:
"users",
]
additional_fields = [
"integration_path",
"last_processing_response",
"b2b_auth_token",
]

View file

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

View file

@ -5,7 +5,6 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0044_vendor_last_processing_response"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
@ -332,27 +331,19 @@ class Migration(migrations.Migration):
),
migrations.AddIndex(
model_name="order",
index=models.Index(
fields=["user", "status"], name="core_order_user_id_4407f8_idx"
),
index=models.Index(fields=["user", "status"], name="core_order_user_id_4407f8_idx"),
),
migrations.AddIndex(
model_name="order",
index=models.Index(
fields=["status", "buy_time"], name="core_order_status_4a088a_idx"
),
index=models.Index(fields=["status", "buy_time"], name="core_order_status_4a088a_idx"),
),
migrations.AddIndex(
model_name="orderproduct",
index=models.Index(
fields=["order", "status"], name="core_orderp_order_i_d16192_idx"
),
index=models.Index(fields=["order", "status"], name="core_orderp_order_i_d16192_idx"),
),
migrations.AddIndex(
model_name="orderproduct",
index=models.Index(
fields=["product", "status"], name="core_orderp_product_ee8abb_idx"
),
index=models.Index(fields=["product", "status"], name="core_orderp_product_ee8abb_idx"),
),
migrations.AddIndex(
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"),
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:
return self.name
@ -652,7 +659,6 @@ class Attribute(ExportModelOperationsMixin("attribute"), NiceModel): # type: ig
max_length=255,
help_text=_("name of this attribute"),
verbose_name=_("attribute's name"),
unique=True,
)
is_filterable = BooleanField(
@ -668,6 +674,7 @@ class Attribute(ExportModelOperationsMixin("attribute"), NiceModel): # type: ig
unique_together = (
"name",
"group",
"value_type",
)
verbose_name = _("attribute")
verbose_name_plural = _("attributes")
@ -732,9 +739,8 @@ class ProductImage(ExportModelOperationsMixin("product_image"), NiceModel): # t
verbose_name=_("product image"),
upload_to=get_product_uuid_as_path,
)
priority = IntegerField(
priority = PositiveIntegerField(
default=1,
validators=[MinValueValidator(1)],
help_text=_("determines the order in which images are displayed"),
verbose_name=_("display priority"),
)

View file

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

View file

@ -2,51 +2,54 @@ from django.conf import settings
from django.contrib.sitemaps import Sitemap
from django.utils.translation import gettext_lazy as _
from blog.models import Post
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"
changefreq = "monthly"
priority = 0.8
limit = 1000
PAGES = [
def items(self):
lang = self._lang()
pages = [
{
"name": _("Home"),
"path": f"/{LANGUAGE_CODE}",
"path": f"/{lang}",
"lastmod": settings.RELEASE_DATE,
},
{
"name": _("Contact Us"),
"path": f"/{LANGUAGE_CODE}/contact-us",
"path": f"/{lang}/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",
"path": f"/{lang}/about-us",
"lastmod": settings.RELEASE_DATE,
},
]
if any_non_digital():
PAGES.append(
for static_post_page in Post.objects.filter(is_static_page=True, is_active=True).only(
"title", "slug", "modified"
):
pages.append(
{
"name": _("Delivery"),
"path": f"/{LANGUAGE_CODE}/help/delivery",
"lastmod": settings.RELEASE_DATE,
"name": static_post_page.title,
"path": f"/{lang}/information/{static_post_page.slug}",
"lastmod": static_post_page.modified,
}
)
def items(self):
return self.PAGES
return pages
def location(self, obj):
return obj["path"]
@ -55,33 +58,7 @@ class StaticPagesSitemap(Sitemap): # type: ignore [type-arg]
return obj.get("lastmod")
# class FeaturedProductsSitemap(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]
class ProductSitemap(SitemapLanguageMixin, Sitemap): # type: ignore [type-arg]
protocol = "https"
changefreq = "daily"
priority = 0.9
@ -104,10 +81,10 @@ class ProductSitemap(Sitemap): # type: ignore [type-arg]
return obj.modified
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"
changefreq = "weekly"
priority = 0.7
@ -120,10 +97,10 @@ class CategorySitemap(Sitemap): # type: ignore [type-arg]
return obj.modified
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"
changefreq = "weekly"
priority = 0.6
@ -136,4 +113,4 @@ class BrandSitemap(Sitemap): # type: ignore [type-arg]
return obj.modified
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
from datetime import date, timedelta
from time import sleep
from typing import Any
from typing import Any, Type
import requests
from celery.app import shared_task
@ -14,7 +14,8 @@ from django.core.cache import cache
from core.models import Product, Promotion
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
logger = get_task_logger(__name__)
@ -39,16 +40,15 @@ def update_products_task() -> tuple[bool, str]:
if not update_products_task_running:
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:
vendor = vendor_class()
for vendor in vendors:
try:
vendor.update_stock()
except VendorInactiveError:
logger.info(f"Skipping {vendor_class} due to inactivity")
logger.info(f"Skipping {vendor.__str__} due to inactivity")
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()
@ -70,10 +70,9 @@ def update_orderproducts_task() -> tuple[bool, str]:
message confirming the successful execution of the task.
:rtype: Tuple[bool, str]
"""
vendors_classes: list[Any] = []
vendors: list[Type[AbstractVendor]] = get_vendors_integrations()
for vendor_class in vendors_classes:
vendor = vendor_class()
for vendor in vendors:
vendor.update_order_products_statuses()
return True, "Success"

View file

@ -1,16 +1,39 @@
import logging
from contextlib import suppress
from typing import Type
from celery import current_task
from celery.utils.log import get_task_logger
from core.models import Vendor
from core.vendors import AbstractVendor
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]]:
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:
if vendor.integration_path:
module_name = ".".join(vendor.integration_path.split(".")[:-1])
class_name = vendor.integration_path.split(".")[-1]
try:
module_name, class_name = vendor.integration_path.rsplit(".", 1)
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

View file

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

View file

@ -222,28 +222,64 @@ LANGUAGES: tuple[tuple[str, str], ...] = (
LANGUAGE_CODE: str = "en-gb"
CURRENCIES: tuple[tuple[str, str], ...] = (
("en-gb", "EUR"),
CURRENCIES_BY_LANGUAGES: tuple[tuple[str, str], ...] = (
("ar-ar", "AED"),
("cs-cz", "CZK"),
("da-dk", "EUR"),
("da-dk", "DKK"),
("de-de", "EUR"),
("en-gb", "GBP"),
("en-us", "USD"),
("es-es", "EUR"),
("fa-ir", "IRR"),
("fr-fr", "EUR"),
("he-il", "ILS"),
("hi-in", "INR"),
("hr-hr", "EUR"),
("id-id", "IDR"),
("it-it", "EUR"),
("ja-jp", "JPY"),
("kk-kz", "KZT"),
("ko-kr", "KRW"),
("nl-nl", "EUR"),
("no-no", "NOK"),
("pl-pl", "PLN"),
("pt-br", "EUR"),
("pt-br", "BRL"),
("ro-ro", "RON"),
("ru-ru", "RUB"),
("sv-se", "SEK"),
("th-th", "THB"),
("tr-tr", "TRY"),
("vi-vn", "VND"),
("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")

View file

@ -5,8 +5,8 @@ from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _
from core.admin import ActivationActionsMixin
from payments.forms import TransactionForm
from payments.models import Balance, Transaction
from payments.forms import GatewayForm, TransactionForm
from payments.models import Balance, Transaction, Gateway
class TransactionInline(admin.TabularInline): # type: ignore [type-arg]
@ -41,3 +41,18 @@ class TransactionAdmin(ActivationActionsMixin, ModelAdmin): # type: ignore [mis
list_filter = ("currency", "payment_method")
ordering = ("balance",)
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 core.widgets import JSONTableWidget
from payments.models import Transaction
from payments.models import Gateway, Transaction
class TransactionForm(forms.ModelForm): # type: ignore [type-arg]
@ -11,3 +11,12 @@ class TransactionForm(forms.ModelForm): # type: ignore [type-arg]
widgets = {
"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):
@ -7,9 +7,9 @@ class UnknownGatewayError(Exception):
class AbstractGateway:
@staticmethod
def process_transaction(transaction: Transaction) -> None:
def process_transaction(transaction: Any) -> None:
raise NotImplementedError
@staticmethod
def process_callback(transaction: Transaction) -> None:
def process_callback(transaction: Any) -> None:
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 django.conf import settings
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 core.abstract import NiceModel
from evibes.utils.misc import create_object
from payments.gateways import AbstractGateway
class Transaction(NiceModel):
@ -20,6 +36,7 @@ class Transaction(NiceModel):
related_name="payments_transactions",
)
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):
return (
@ -67,3 +84,81 @@ class Balance(NiceModel):
if self.amount != 0.0 and len(str(self.amount).split(".")[1]) > 2:
self.amount = round(self.amount, 2)
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.dispatch import receiver
from payments.gateways import AbstractGateway
from payments.models import Balance, Transaction
from payments.utils.emailing import balance_deposit_email
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)
def process_transaction_changes(instance: Transaction, created: bool, **kwargs: dict[Any, Any]) -> None:
if created:
if not instance.gateway:
raise ValueError("gateway is required to process a transaction")
try:
gateway = None
match instance.process.get("gateway", "default"):
case "gateway":
gateway = AbstractGateway()
case "default":
gateway = AbstractGateway()
case _:
gateway = AbstractGateway()
gateway = instance.gateway.get_integration_class_object()
gateway.process_transaction(instance)
except Exception as e:
instance.process = {"status": "ERRORED", "error": str(e)}