Merge branch 'main' into storefront-nuxt
This commit is contained in:
commit
ea53f398a3
23 changed files with 921 additions and 268 deletions
129
README.md
129
README.md
|
|
@ -2,57 +2,55 @@
|
|||
|
||||

|
||||
|
||||
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 e‑commerce 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 you’ll 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.
|
||||
|
||||

|
||||
21
blog/migrations/0007_post_is_static_page.py
Normal file
21
blog/migrations/0007_post_is_static_page.py
Normal 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
16
core/migrations/0047_alter_attribute_unique_together.py
Normal file
16
core/migrations/0047_alter_attribute_unique_together.py
Normal 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")},
|
||||
),
|
||||
]
|
||||
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -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"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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'}"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
26
core/vendors/__init__.py
vendored
26
core/vendors/__init__.py
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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", "Kč"),
|
||||
("DKK", "kr"),
|
||||
("EUR", "€"),
|
||||
("GBP", "£"),
|
||||
("IDR", "Rp"),
|
||||
("ILS", "₪"),
|
||||
("INR", "₹"),
|
||||
("IRR", "﷼"),
|
||||
("JPY", "¥"),
|
||||
("KRW", "₩"),
|
||||
("KZT", "₸"),
|
||||
("NOK", "kr"),
|
||||
("PLN", "zł"),
|
||||
("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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
147
payments/migrations/0005_gateway_transaction_gateway.py
Normal file
147
payments/migrations/0005_gateway_transaction_gateway.py
Normal 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", "Kč"),
|
||||
("DKK", "kr"),
|
||||
("EUR", "€"),
|
||||
("GBP", "£"),
|
||||
("IDR", "Rp"),
|
||||
("ILS", "₪"),
|
||||
("INR", "₹"),
|
||||
("IRR", "﷼"),
|
||||
("JPY", "¥"),
|
||||
("KRW", "₩"),
|
||||
("KZT", "₸"),
|
||||
("NOK", "kr"),
|
||||
("PLN", "zł"),
|
||||
("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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue