diff --git a/Makefile b/Makefile index 3b9c86d..c0da283 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,9 @@ all: migrations migrate run run: $(MANAGE_PY) runserver +bot: + $(MANAGE_PY) runbot + migrations: $(MANAGE_PY) makemigrations @@ -50,4 +53,4 @@ docker-build: docker-run: docker-build docker run --rm -p 8000:8000 -e ALLOWED_HOSTS='*' --name $(PROJECT_NAME) $(PROJECT_NAME) -.PHONY: all run migrations migrate test shell superuser check format lint clean docker-build docker-run +.PHONY: all run bot migrations migrate test shell superuser check format lint clean docker-build docker-run diff --git a/backend/bot/bot.py b/backend/bot/bot.py index a7fb39e..e2155ca 100644 --- a/backend/bot/bot.py +++ b/backend/bot/bot.py @@ -1,13 +1,15 @@ import logging -from aiogram import Bot, Dispatcher +from aiogram import Bot, Dispatcher, html from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode from aiogram.filters.command import Command, CommandStart from aiogram.types import Message -from diary.models import Plant +from catalogs.models import PlantType, PlantVariety +from diary.models import Plant, SeedStock from django.conf import settings from django.contrib.auth import get_user_model +from django.db.models import F from django.db.utils import IntegrityError from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -79,7 +81,7 @@ async def start(message: Message) -> None: await message.answer( str( _( - "Welcome, {user}! I'm your plant care assistant bot. Use /myplants to see your plants or /today to check today's tasks." + "Welcome, {user}! I'm your plant care assistant bot. Use /seeds to view seed stock, /addseeds to add seeds, /plant to plant from stock, /myplants to see your plants, or /today to check today's tasks." ).format(user=tg_user) ) ) @@ -138,6 +140,182 @@ async def today(message: Message): await message.answer(str(answer)) +@dp.message(Command("seeds")) +async def seeds(message: Message): + if not message.from_user: + logger.warning("Received message without user information") + return + + answer = _("Your seed stock:") + "\n\n" + found = False + + stocks = ( + SeedStock.objects.filter( + user__telegramuser__id=message.from_user.id, quantity__gt=0 + ) + .select_related("type", "variety") + .order_by("type__name", "variety__name") + ) + + async for stock in stocks: + found = True + plant_name = stock.type.name + if stock.variety: + plant_name += f" {stock.variety.name}" + answer += html.quote(f"{stock.id}. 🌱 {plant_name} — {stock.quantity}") + "\n" + + if not found: + answer = _("Your seed stock is empty.") + else: + answer += "\n" + html.quote( + _("Use /addseeds to add seeds.") + ) + answer += "\n" + html.quote(_("Use /plant to plant one seed.")) + + await message.answer(str(answer)) + + +@dp.message(Command("addseeds")) +async def add_seeds(message: Message): + if not message.from_user: + logger.warning("Received message without user information") + return + + parts = (message.text or "").split() + if len(parts) != 4 or not parts[2].isdigit(): + await message.answer( + html.quote(str(_("Usage: /addseeds "))) + ) + return + + type_slug = parts[1].strip().lower() + quantity = int(parts[2].strip()) + variety_slug = parts[3].strip().lower() + + if quantity <= 0: + await message.answer(html.quote(str(_("Quantity must be greater than 0.")))) + return + + try: + plant_type = await PlantType.objects.aget(slug=type_slug) + except PlantType.DoesNotExist: + await message.answer(html.quote(str(_("Plant type not found.")))) + return + + try: + variety = await PlantVariety.objects.aget(type=plant_type, slug=variety_slug) + except PlantVariety.DoesNotExist: + await message.answer( + html.quote(str(_("Plant variety not found for this type."))) + ) + return + + try: + tg_user = await TelegramUser.objects.select_related("user").aget( + id=message.from_user.id + ) + except TelegramUser.DoesNotExist: + await message.answer( + html.quote( + str(_("You must be registered to use this command. Use /start.")) + ) + ) + return + + stock_qs = SeedStock.objects.filter( + user=tg_user.user, + type=plant_type, + variety=variety, + ) + updated = await stock_qs.aupdate(quantity=F("quantity") + quantity) + + if updated == 0: + try: + await SeedStock.objects.acreate( + user=tg_user.user, + type=plant_type, + variety=variety, + quantity=quantity, + ) + except IntegrityError: + # Row was inserted concurrently; apply increment to the existing row. + await stock_qs.aupdate(quantity=F("quantity") + quantity) + + stock = await stock_qs.select_related("type", "variety").aget() + + plant_name = stock.type.name + if stock.variety: + plant_name += f" {stock.variety.name}" + + await message.answer( + html.quote( + str( + _("Added {count} seeds to stock: {plant_name}. Total: {total}.").format( + count=quantity, + plant_name=plant_name, + total=stock.quantity, + ) + ) + ) + ) + + +@dp.message(Command("plant")) +async def plant_from_seed_stock(message: Message, user): + if not message.from_user: + logger.warning("Received message without user information") + return + + if not user: + await message.answer( + html.quote( + str(_("You must be registered to use this command. Use /start.")) + ) + ) + return + + parts = (message.text or "").split(maxsplit=1) + if len(parts) < 2 or not parts[1].strip().isdigit(): + await message.answer(html.quote(str(_("Usage: /plant ")))) + return + + stock_id = int(parts[1].strip()) + + try: + stock = await SeedStock.objects.select_related("type", "variety").aget( + id=stock_id, user__telegramuser__id=message.from_user.id + ) + except SeedStock.DoesNotExist: + await message.answer(html.quote(str(_("Seed stock not found.")))) + return + + # Atomic decrement with quantity check (prevents race conditions) + # Replaces non-atomic in-memory decrement to avoid concurrent request corruption + # Uses conditional filter + aupdate for database-level atomicity + stock_qs = SeedStock.objects.filter(id=stock.id, quantity__gt=0) + updated = await stock_qs.aupdate(quantity=F("quantity") - 1) + + if updated == 0: + await message.answer(html.quote(str(_("This seed stock is empty.")))) + return + + plant = Plant(user=stock.user, type=stock.type, variety=stock.variety) + await plant.asave() + + # Refresh stock to get database-updated quantity for response + stock = await SeedStock.objects.aget(id=stock.id) + + await message.answer( + html.quote( + str( + _("Planted: {plant_name}. Remaining seeds: {quantity}.").format( + plant_name=plant.name, quantity=stock.quantity + ) + ) + ) + ) + + @dp.message(Command("planting")) async def planting(message: Message): if not message.from_user: diff --git a/backend/bot/middlewares.py b/backend/bot/middlewares.py index 156aa5e..b19e9a6 100644 --- a/backend/bot/middlewares.py +++ b/backend/bot/middlewares.py @@ -27,6 +27,8 @@ async def __call__( else: timezone.deactivate() except UserModel.DoesNotExist: + data["user"] = None + data["profile"] = None return await handler(event, data) data["user"] = user diff --git a/backend/diary/admin.py b/backend/diary/admin.py index 10668ac..99fdb54 100644 --- a/backend/diary/admin.py +++ b/backend/diary/admin.py @@ -6,7 +6,7 @@ from django.http import HttpRequest from django.utils.translation import gettext_lazy as _ -from .models import Plant, PlantEvent, Profile +from .models import Plant, PlantEvent, Profile, SeedStock # Register your models here. @@ -88,3 +88,13 @@ def get_formsets_with_inlines( for i in PlantStep.objects.filter(plant_type=obj.type) ] yield inline.get_formset(request, obj), inline + + +@admin.register(SeedStock) +class SeedStockAdmin(admin.ModelAdmin): + list_display = ("user", "type", "variety", "quantity") + list_filter = ("user", "type", "variety") + search_fields = ("user__username", "type__name", "variety__name") + + def get_queryset(self, request: HttpRequest) -> QuerySet[Any]: + return super().get_queryset(request).select_related("user", "type", "variety") diff --git a/backend/diary/migrations/0003_seedstock.py b/backend/diary/migrations/0003_seedstock.py new file mode 100644 index 0000000..15f573f --- /dev/null +++ b/backend/diary/migrations/0003_seedstock.py @@ -0,0 +1,70 @@ +# Generated by Django 5.2 on 2026-02-28 05:27 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("catalogs", "0004_alter_planttype_planting_period_and_more"), + ("diary", "0002_profile"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="SeedStock", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "quantity", + models.PositiveIntegerField( + default=0, verbose_name="Количество семян" + ), + ), + ( + "type", + models.ForeignKey( + on_delete=django.db.models.deletion.RESTRICT, + related_name="seed_stocks", + to="catalogs.planttype", + verbose_name="Тип", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="seed_stocks", + to=settings.AUTH_USER_MODEL, + verbose_name="Пользователь", + ), + ), + ( + "variety", + models.ForeignKey( + on_delete=django.db.models.deletion.RESTRICT, + related_name="seed_stocks", + to="catalogs.plantvariety", + verbose_name="Сорт", + ), + ), + ], + options={ + "verbose_name": "Запас семян", + "verbose_name_plural": "Запасы семян", + "ordering": ("type__name", "variety__name"), + "unique_together": {("user", "type", "variety")}, + }, + ), + ] diff --git a/backend/diary/models.py b/backend/diary/models.py index 4f9bb71..6af95b3 100644 --- a/backend/diary/models.py +++ b/backend/diary/models.py @@ -200,6 +200,44 @@ def __str__(self): return f"{self.name}" +class SeedStock(models.Model): + user = models.ForeignKey( + UserModel, + verbose_name=_("Пользователь"), + on_delete=models.CASCADE, + related_name="seed_stocks", + ) + type = models.ForeignKey( + "catalogs.PlantType", + verbose_name=_("Тип"), + on_delete=models.RESTRICT, + related_name="seed_stocks", + ) + variety = models.ForeignKey( + "catalogs.PlantVariety", + verbose_name=_("Сорт"), + on_delete=models.RESTRICT, + related_name="seed_stocks", + ) + quantity = models.PositiveIntegerField( + verbose_name=_("Количество семян"), default=0 + ) + + class Meta: + verbose_name = _("Запас семян") + verbose_name_plural = _("Запасы семян") + unique_together = ("user", "type", "variety") + ordering = ("type__name", "variety__name") + + def save(self, *args, **kwargs): + self.type = self.variety.type + super().save(*args, **kwargs) + + def __str__(self): + name = f"{self.type.name} {self.variety.name}" + return f"{name}: {self.quantity}" + + class PlantEvent(models.Model): plant = models.ForeignKey( Plant, diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index 64933b3..371ff29 100755 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -7,7 +7,6 @@ HOST=${HOST:-0.0.0.0} BOT_MODE=${BOT_MODE:-webhook} python manage.py migrate -python manage.py syncbot case "$BOT_MODE" in webhook)