Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ all: migrations migrate run
run:
$(MANAGE_PY) runserver

bot:
$(MANAGE_PY) runbot

migrations:
$(MANAGE_PY) makemigrations

Expand Down Expand Up @@ -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
184 changes: 181 additions & 3 deletions backend/bot/bot.py
Original file line number Diff line number Diff line change
@@ -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 _
Expand Down Expand Up @@ -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 <type_slug> <quantity> <variety_slug> to add seeds, /plant <seed_stock_id> to plant from stock, /myplants to see your plants, or /today to check today's tasks."
).format(user=tg_user)
)
)
Expand Down Expand Up @@ -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 <type_slug> <quantity> <variety_slug> to add seeds.")
)
answer += "\n" + html.quote(_("Use /plant <seed_stock_id> 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 <type_slug> <quantity> <variety_slug>")))
)
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()
Comment thread
capcom6 marked this conversation as resolved.

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):
Comment thread
capcom6 marked this conversation as resolved.
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 <seed_stock_id>"))))
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)

Comment thread
capcom6 marked this conversation as resolved.
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:
Expand Down
2 changes: 2 additions & 0 deletions backend/bot/middlewares.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion backend/diary/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")
70 changes: 70 additions & 0 deletions backend/diary/migrations/0003_seedstock.py
Original file line number Diff line number Diff line change
@@ -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")},
},
),
]
38 changes: 38 additions & 0 deletions backend/diary/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
Comment thread
capcom6 marked this conversation as resolved.
quantity = models.PositiveIntegerField(
verbose_name=_("Количество семян"), default=0
)

class Meta:
verbose_name = _("Запас семян")
verbose_name_plural = _("Запасы семян")
unique_together = ("user", "type", "variety")
ordering = ("type__name", "variety__name")
Comment thread
capcom6 marked this conversation as resolved.

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,
Expand Down
1 change: 0 additions & 1 deletion scripts/docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down