diff --git a/.bandit b/.bandit new file mode 100644 index 0000000..924be55 --- /dev/null +++ b/.bandit @@ -0,0 +1,3 @@ +[bandit] +exclude_dirs = ["tests", "venv", ".venv"] +skips = ["B101", "B601"] \ No newline at end of file diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..0218490 --- /dev/null +++ b/.flake8 @@ -0,0 +1,15 @@ +[flake8] +max-line-length = 88 +extend-ignore = E203, W503, E501 +exclude = + .git, + __pycache__, + venv, + .venv, + migrations, + .tox, + build, + dist +per-file-ignores = + __init__.py:F401 + settings.py:E501 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a7e57c --- /dev/null +++ b/.gitignore @@ -0,0 +1,79 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Virtual environments +venv/ +env/ +ENV/ +env.bak/ +venv.bak/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +Thumbs.db + +# Testing +.coverage +.pytest_cache/ +htmlcov/ +.tox/ +.cache +nosetests.xml +coverage.xml + +# Static files +/static/ +/staticfiles/ +/media/ + +# Linting +.mypy_cache/ +.flake8_cache/ + +# Pre-commit +.pre-commit-config.yaml.bak + +# Project specific +*.sqlite3 +*.db + +# VS Code workspace +*.code-workspace \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a2f0b45 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,32 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: check-merge-conflict + + - repo: https://github.com/psf/black + rev: 23.3.0 + hooks: + - id: black + language_version: python3 + + - repo: https://github.com/pycqa/isort + rev: 5.12.0 + hooks: + - id: isort + + - repo: https://github.com/pycqa/flake8 + rev: 6.0.0 + hooks: + - id: flake8 + + - repo: https://github.com/pycqa/bandit + rev: 1.7.5 + hooks: + - id: bandit + args: ['-r', '.'] + exclude: tests/ \ No newline at end of file diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..ddd268d --- /dev/null +++ b/.pylintrc @@ -0,0 +1,21 @@ +[MASTER] +load-plugins = pylint_django +django-settings-module = nalo_automate.settings + +[FORMAT] +max-line-length = 88 + +[MESSAGES CONTROL] +disable = + missing-docstring, + too-few-public-methods, + import-error, + no-member, + unused-argument + +[DESIGN] +max-args = 7 +max-locals = 15 +max-returns = 6 +max-branches = 12 +max-statements = 50 \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..451cabb --- /dev/null +++ b/Makefile @@ -0,0 +1,171 @@ +# Makefile for Nalo Ice Cream Automate +# Professional Django development workflow + +.PHONY: help install run clean test lint format check init mock serve logs shell migrate docs build commit cz-commit bump changelog + +# Default target +help: ## Show this help message + @echo "Nalo Ice Cream Automate - Available commands:" + @echo "" + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) + @echo "" + +# Development environment +install: ## Install dependencies and setup environment + python -m venv venv || true + . venv/bin/activate && pip install --upgrade pip + . venv/bin/activate && pip install -r requirements.txt + @echo "✅ Dependencies installed" + +init: ## Initialize project for first time (migrations + mock data) + . venv/bin/activate && python manage.py makemigrations ice_cream + . venv/bin/activate && python manage.py migrate + . venv/bin/activate && python manage.py init_flavors + @echo "✅ Project initialized with database and flavors" + +mock: ## Generate mock data for testing + . venv/bin/activate && python manage.py generate_sample_orders --count 10 + @echo "✅ Mock data generated" + +# Server management +run: ## Run development server + . venv/bin/activate && python manage.py runserver + +serve: ## Alternative to run (same as run) + $(MAKE) run + +logs: ## Show server logs (if running in background) + tail -f nohup.out + +# Database operations +migrate: ## Apply database migrations + . venv/bin/activate && python manage.py makemigrations + . venv/bin/activate && python manage.py migrate + @echo "✅ Migrations applied" + +shell: ## Open Django shell + . venv/bin/activate && python manage.py shell + +# Cleaning +clean: ## Clean cache and temporary files + find . -name "*.pyc" -delete + find . -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true + find . -name "*.pyo" -delete + find . -name ".coverage" -delete + find . -name "htmlcov" -type d -exec rm -rf {} + 2>/dev/null || true + find . -name ".pytest_cache" -type d -exec rm -rf {} + 2>/dev/null || true + @echo "✅ Cache and temporary files cleaned" + +# Code quality +format: ## Format code with black and isort + . venv/bin/activate && black --line-length 88 . + . venv/bin/activate && isort --profile black . + @echo "✅ Code formatted with black and isort" + +lint: ## Run linting with flake8 and pylint + . venv/bin/activate && flake8 --max-line-length=88 --extend-ignore=E203,W503 . + . venv/bin/activate && pylint --load-plugins=pylint_django --django-settings-module=nalo_automate.settings ice_cream/ nalo_automate/ || true + @echo "✅ Linting completed" + +check: ## Run all code quality checks + $(MAKE) format + $(MAKE) lint + . venv/bin/activate && python manage.py check + @echo "✅ All code quality checks completed" + +fix: ## Auto-fix common issues + . venv/bin/activate && autopep8 --in-place --aggressive --aggressive --recursive . + . venv/bin/activate && black --line-length 88 . + . venv/bin/activate && isort --profile black . + @echo "✅ Auto-fixes applied" + +# Testing +test: ## Run all tests + . venv/bin/activate && python manage.py test --verbosity=2 + +test-coverage: ## Run tests with coverage report + . venv/bin/activate && coverage run --source='.' manage.py test + . venv/bin/activate && coverage report + . venv/bin/activate && coverage html + @echo "✅ Tests completed with coverage report in htmlcov/" + +test-fast: ## Run tests without coverage (faster) + . venv/bin/activate && python manage.py test --parallel --keepdb + +# Commitizen +commit: cz-commit +cz-commit: + @echo "Starting interactive commit..." + cz commit + +bump: + @echo "Bumping version..." + cz bump + +bump-dry: + @echo "Dry run version bump..." + cz bump --dry-run + +changelog: + @echo "Generating changelog..." + cz changelog + +pre-commit-install: + @echo "Installing pre-commit hooks..." + pip install pre-commit + pre-commit install + pre-commit install --hook-type commit-msg + +validate-commit: + cz check --rev-range HEAD + +# Documentation +docs: ## Generate API documentation + @echo "📚 API Documentation available at:" + @echo " - Swagger UI: http://127.0.0.1:8000/api/docs/" + @echo " - ReDoc: http://127.0.0.1:8000/api/redoc/" + @echo " - Schema: http://127.0.0.1:8000/api/schema/" + + +# Development workflow +dev: ## Complete development setup + $(MAKE) clean + $(MAKE) install + $(MAKE) init + $(MAKE) mock + $(MAKE) check + $(MAKE) test + @echo "🚀 Development environment ready!" + @echo "Run 'make run' to start the server" + +# Quick commands +quick-test: ## Quick test run (most common) + $(MAKE) clean + $(MAKE) format + $(MAKE) test-fast + +restart: ## Clean restart + $(MAKE) clean + $(MAKE) run + +kill-server: ## Kill any running server on port 8000 + lsof -ti:8000 | xargs kill -9 2>/dev/null || true + @echo "✅ Server killed" + +# Status +status: ## Show project status + @echo "📊 Nalo Ice Cream Automate Status:" + @echo "" + @echo "🐍 Python environment:" + @. venv/bin/activate && python --version + @echo "" + @echo "📦 Django status:" + @. venv/bin/activate && python manage.py check --deploy 2>/dev/null && echo "✅ Django OK" || echo "❌ Django issues" + @echo "" + @echo "🗄️ Database:" + @. venv/bin/activate && python manage.py showmigrations --plan | tail -5 + @echo "" + @echo "🍦 Flavors in DB:" + @. venv/bin/activate && python manage.py shell -c "from ice_cream.models import Flavor; print(f'{Flavor.objects.count()} flavors')" 2>/dev/null || echo "Not initialized" + @echo "" + @echo "📋 Available commands: make help" \ No newline at end of file diff --git a/README.md b/README.md index 41d374d..437ceaa 100644 --- a/README.md +++ b/README.md @@ -36,3 +36,41 @@ Un utilisateur a le choix du nombre de boule et des parfums. - [ ] Publie-le sur GitHub en tant que `pull-request` - [ ] Envoie-nous le lien et dis-nous approximativement combien de temps tu as passé sur ce travail. +# Nalo Ice Cream Automate + +Système de gestion d'automate de glaces avec API REST et interface web. + +## Installation et lancement + +```bash +# Setup complet (première fois) +make dev + +# Ou étape par étape +make install # Installation des dépendances +make init # Base de données + parfums +make run # Lancement du serveur +``` + +Accès : http://127.0.0.1:8000 + +## Fonctionnalités + +* API : `/api/orders/`, `/api/flavors/`, `/api/refill-pot/` +* Interface : Commande, récupération, administration +* Documentation : http://127.0.0.1:8000/api/docs/ + +## Tests et qualité + +```bash +make test +make test-coverage +make check +make clean +``` + +## Structure + +5 parfums disponibles (Chocolat Orange, Cerise, Pistache, Vanille, Framboise) +40 boules par pot, 2€ par boule +Gestion automatique des stocks + alertes \ No newline at end of file diff --git a/cherry.jpg b/cherry.jpg deleted file mode 100644 index 5a29710..0000000 Binary files a/cherry.jpg and /dev/null differ diff --git a/chocolate-orange.jpg b/chocolate-orange.jpg deleted file mode 100644 index 38d8963..0000000 Binary files a/chocolate-orange.jpg and /dev/null differ diff --git a/ice_cream/__init__.py b/ice_cream/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ice_cream/admin.py b/ice_cream/admin.py new file mode 100644 index 0000000..ca306c7 --- /dev/null +++ b/ice_cream/admin.py @@ -0,0 +1,118 @@ +from django.contrib import admin + +from .models import AdminSettings, Flavor, Order, OrderItem + + +@admin.register(Flavor) +class FlavorAdmin(admin.ModelAdmin): + """ + Admin interface for ice cream flavors + """ + + list_display = [ + "get_name_display", + "stock", + "fill_rate_display", + "is_empty_display", + ] + list_filter = ["name"] + actions = ["refill_selected_pots"] + readonly_fields = ["fill_rate_display", "is_empty_display"] + + def fill_rate_display(self, obj): + """Display fill rate as percentage""" + return f"{obj.fill_rate:.1f}%" + + fill_rate_display.short_description = "Taux de remplissage" + + def is_empty_display(self, obj): + """Display empty status with icon""" + if obj.is_empty: + return "🔴 Vide" + elif obj.stock < 10: + return "🟡 Faible" + else: + return "🟢 OK" + + is_empty_display.short_description = "Statut" + + def refill_selected_pots(self, request, queryset): + """Admin action to refill selected pots""" + count = queryset.count() + for flavor in queryset: + flavor.refill_pot() + self.message_user(request, f"{count} pot(s) rempli(s) à 40 boules.") + + refill_selected_pots.short_description = "Remplir les pots sélectionnés" + + +class OrderItemInline(admin.TabularInline): + """ + Inline admin for order items + """ + + model = OrderItem + extra = 0 + readonly_fields = ["item_price_display"] + + def item_price_display(self, obj): + """Display item price""" + return f"{obj.item_price}€" + + item_price_display.short_description = "Prix" + + +@admin.register(Order) +class OrderAdmin(admin.ModelAdmin): + """ + Admin interface for orders + """ + + list_display = ["order_code", "total_price", "total_scoops_display", "created_at"] + list_filter = ["created_at"] + search_fields = ["order_code"] + readonly_fields = [ + "order_code", + "total_price", + "created_at", + "total_scoops_display", + ] + inlines = [OrderItemInline] + date_hierarchy = "created_at" + + def total_scoops_display(self, obj): + """Display total scoops count""" + return f"{obj.total_scoops} boule(s)" + + total_scoops_display.short_description = "Total boules" + + def has_add_permission(self, request): + """Prevent manual order creation in admin""" + return False + + +@admin.register(AdminSettings) +class AdminSettingsAdmin(admin.ModelAdmin): + """ + Admin interface for settings + """ + + list_display = ["admin_email", "low_stock_threshold"] + fieldsets = ( + ("Configuration Email", {"fields": ("admin_email",)}), + ("Seuils d'alerte", {"fields": ("low_stock_threshold",)}), + ) + + def has_add_permission(self, request): + """Only allow one settings instance""" + return not AdminSettings.objects.exists() + + def has_delete_permission(self, request, obj=None): + """Prevent settings deletion""" + return False + + +# Customize admin site headers +admin.site.site_header = "Administration Automate Nalo" +admin.site.site_title = "Nalo Admin" +admin.site.index_title = "Gestion de l'automate de glaces" diff --git a/ice_cream/apps.py b/ice_cream/apps.py new file mode 100644 index 0000000..1796c03 --- /dev/null +++ b/ice_cream/apps.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig + + +class IceCreamConfig(AppConfig): + """ + Configuration for the ice cream app + """ + + default_auto_field = "django.db.models.BigAutoField" + name = "ice_cream" + verbose_name = "Automate de Glaces" diff --git a/ice_cream/management/__init__.py b/ice_cream/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ice_cream/management/commands/__init__.py b/ice_cream/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ice_cream/management/commands/generate_sample_orders.py b/ice_cream/management/commands/generate_sample_orders.py new file mode 100644 index 0000000..ab78cf3 --- /dev/null +++ b/ice_cream/management/commands/generate_sample_orders.py @@ -0,0 +1,105 @@ +import random + +from django.core.management.base import BaseCommand +from django.db import transaction + +from ice_cream.models import Flavor, Order, OrderItem + + +class Command(BaseCommand): + """ + Management command to generate sample orders for testing + """ + + help = "Generate sample orders for testing purposes" + + def add_arguments(self, parser): + """Add command arguments""" + parser.add_argument( + "--count", + type=int, + default=10, + help="Number of orders to generate (default: 10)", + ) + parser.add_argument( + "--max-items", + type=int, + default=3, + help="Maximum items per order (default: 3)", + ) + + def handle(self, *args, **options): + """Execute the command""" + order_count = options["count"] + max_items = options["max_items"] + + if not Flavor.objects.exists(): + self.stdout.write( + self.style.ERROR( + 'No flavors found. Run "python manage.py init_flavors" first.' + ) + ) + return + + flavors = list(Flavor.objects.all()) + generated_orders = [] + + self.stdout.write(f"Generating {order_count} sample orders...") + + with transaction.atomic(): + for i in range(order_count): + # Random number of items per order (1 to max_items) + num_items = random.randint(1, max_items) + + # Select random flavors + selected_flavors = random.sample(flavors, num_items) + + total_price = 0 + order_items_data = [] + + for flavor in selected_flavors: + # Random quantity (1 to 5 scoops) + quantity = random.randint(1, min(5, flavor.stock)) + + if quantity > 0: + order_items_data.append( + {"flavor": flavor, "quantity": quantity} + ) + total_price += quantity * 2 + + if order_items_data: + # Create order + order = Order.objects.create(total_price=total_price) + + # Create order items + for item_data in order_items_data: + OrderItem.objects.create( + order=order, + flavor=item_data["flavor"], + quantity=item_data["quantity"], + ) + + # Update stock + flavor = item_data["flavor"] + flavor.consume_scoops(item_data["quantity"]) + + generated_orders.append(order) + + self.stdout.write( + f" ✓ Order {order.order_code}: {total_price}€ ({order.total_scoops} scoops)" + ) + + self.stdout.write("") + self.stdout.write( + self.style.SUCCESS( + f"Successfully generated {len(generated_orders)} orders!" + ) + ) + + # Show updated stock levels + self.stdout.write("\nUpdated stock levels:") + for flavor in Flavor.objects.all(): + status = "🔴" if flavor.is_empty else "🟡" if flavor.stock < 10 else "🟢" + self.stdout.write( + f" {status} {flavor.get_name_display()}: {flavor.stock} scoops" + ) diff --git a/ice_cream/management/commands/init_flavors.py b/ice_cream/management/commands/init_flavors.py new file mode 100644 index 0000000..58a357d --- /dev/null +++ b/ice_cream/management/commands/init_flavors.py @@ -0,0 +1,75 @@ +from django.core.management.base import BaseCommand + +from ice_cream.models import Flavor + + +class Command(BaseCommand): + """ + Management command to initialize ice cream flavors + """ + + help = "Initialize ice cream flavors with default stock" + + def add_arguments(self, parser): + """Add command arguments""" + parser.add_argument( + "--stock", + type=int, + default=40, + help="Initial stock for each flavor (default: 40)", + ) + parser.add_argument( + "--force", action="store_true", help="Force update existing flavors" + ) + + def handle(self, *args, **options): + """Execute the command""" + initial_stock = options["stock"] + force_update = options["force"] + + flavors_data = [ + ("chocolate_orange", "Chocolat Orange"), + ("cherry", "Cerise"), + ("pistachio", "Pistache"), + ("vanilla", "Vanille"), + ("raspberry", "Framboise"), + ] + + created_count = 0 + updated_count = 0 + + for flavor_code, flavor_display in flavors_data: + flavor, created = Flavor.objects.get_or_create( + name=flavor_code, defaults={"stock": initial_stock} + ) + + if created: + created_count += 1 + self.stdout.write( + self.style.SUCCESS( + f"✓ Created flavor: {flavor_display} with {initial_stock} scoops" + ) + ) + elif force_update: + flavor.stock = initial_stock + flavor.save() + updated_count += 1 + self.stdout.write( + self.style.WARNING( + f"↻ Updated flavor: {flavor_display} to {initial_stock} scoops" + ) + ) + else: + self.stdout.write( + self.style.WARNING( + f"- Flavor already exists: {flavor_display} ({flavor.stock} scoops)" + ) + ) + + # Summary + self.stdout.write("") + self.stdout.write(self.style.SUCCESS(f"Initialization complete!")) + self.stdout.write(f" - Created: {created_count} flavors") + if force_update: + self.stdout.write(f" - Updated: {updated_count} flavors") + self.stdout.write(f" - Total flavors: {Flavor.objects.count()}") diff --git a/ice_cream/management/commands/reset_stocks.py b/ice_cream/management/commands/reset_stocks.py new file mode 100644 index 0000000..64fe949 --- /dev/null +++ b/ice_cream/management/commands/reset_stocks.py @@ -0,0 +1,52 @@ +from django.core.management.base import BaseCommand + +from ice_cream.models import Flavor + + +class Command(BaseCommand): + """ + Management command to reset all flavor stocks to full capacity + """ + + help = "Reset all flavor stocks to 40 scoops" + + def add_arguments(self, parser): + """Add command arguments""" + parser.add_argument( + "--confirm", action="store_true", help="Confirm the reset operation" + ) + + def handle(self, *args, **options): + """Execute the command""" + if not options["confirm"]: + self.stdout.write( + self.style.WARNING( + "This will reset ALL flavor stocks to 40 scoops.\n" + "Use --confirm flag to proceed." + ) + ) + return + + flavors = Flavor.objects.all() + + if not flavors.exists(): + self.stdout.write( + self.style.ERROR( + 'No flavors found. Run "python manage.py init_flavors" first.' + ) + ) + return + + self.stdout.write("Resetting all flavor stocks...") + + for flavor in flavors: + old_stock = flavor.stock + flavor.refill_pot() + self.stdout.write( + f" ✓ {flavor.get_name_display()}: {old_stock} → 40 scoops" + ) + + self.stdout.write("") + self.stdout.write( + self.style.SUCCESS(f"Successfully reset {flavors.count()} flavors!") + ) diff --git a/ice_cream/migrations/0001_initial.py b/ice_cream/migrations/0001_initial.py new file mode 100644 index 0000000..307c689 --- /dev/null +++ b/ice_cream/migrations/0001_initial.py @@ -0,0 +1,127 @@ +# Generated by Django 4.2 on 2025-06-10 10:56 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="AdminSettings", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "admin_email", + models.EmailField(default="admin@nalo.com", max_length=254), + ), + ("low_stock_threshold", models.IntegerField(default=5)), + ], + options={ + "verbose_name": "Admin Configuration", + "verbose_name_plural": "Admin Configurations", + }, + ), + migrations.CreateModel( + name="Flavor", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField( + choices=[ + ("chocolate_orange", "Chocolat Orange"), + ("cherry", "Cerise"), + ("pistachio", "Pistache"), + ("vanilla", "Vanille"), + ("raspberry", "Framboise"), + ], + max_length=50, + unique=True, + ), + ), + ( + "stock", + models.IntegerField( + default=40, + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + ], + ), + migrations.CreateModel( + name="Order", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "order_code", + models.CharField(editable=False, max_length=8, unique=True), + ), + ("total_price", models.DecimalField(decimal_places=2, max_digits=10)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name="OrderItem", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "quantity", + models.IntegerField( + validators=[django.core.validators.MinValueValidator(1)] + ), + ), + ( + "flavor", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="ice_cream.flavor", + ), + ), + ( + "order", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="ice_cream.order", + ), + ), + ], + ), + ] diff --git a/ice_cream/migrations/__init__.py b/ice_cream/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ice_cream/models.py b/ice_cream/models.py new file mode 100644 index 0000000..1413a47 --- /dev/null +++ b/ice_cream/models.py @@ -0,0 +1,125 @@ +# models.py +import random +import string +import uuid + +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models + + +class Flavor(models.Model): + """ + Ice cream flavor model with stock management + """ + + FLAVOR_CHOICES = [ + ("chocolate_orange", "Chocolat Orange"), + ("cherry", "Cerise"), + ("pistachio", "Pistache"), + ("vanilla", "Vanille"), + ("raspberry", "Framboise"), + ] + + name = models.CharField(max_length=50, choices=FLAVOR_CHOICES, unique=True) + stock = models.IntegerField(default=40, validators=[MinValueValidator(0)]) + + def __str__(self): + return self.get_name_display() + + @property + def is_empty(self): + """Check if the pot is empty""" + return self.stock == 0 + + @property + def fill_rate(self): + """Calculate fill rate percentage""" + return (self.stock / 40) * 100 + + def refill_pot(self): + """Refill the pot to 40 scoops""" + self.stock = 40 + self.save() + + def consume_scoops(self, quantity): + """ + Consume scoops from stock + Returns True if successful, False if insufficient stock + """ + if self.stock >= quantity: + self.stock -= quantity + self.save() + return True + return False + + +class Order(models.Model): + """ + Customer order with unique code generation + """ + + order_code = models.CharField(max_length=8, unique=True, editable=False) + total_price = models.DecimalField(max_digits=10, decimal_places=2) + created_at = models.DateTimeField(auto_now_add=True) + + def save(self, *args, **kwargs): + if not self.order_code: + self.order_code = self._generate_unique_code() + super().save(*args, **kwargs) + + def _generate_unique_code(self): + """Generate a unique 8-character order code""" + while True: + code = "".join(random.choices(string.ascii_uppercase + string.digits, k=8)) + if not Order.objects.filter(order_code=code).exists(): + return code + + @property + def total_scoops(self): + """Calculate total number of scoops in the order""" + return sum(item.quantity for item in self.orderitem_set.all()) + + def __str__(self): + return f"Order {self.order_code} - {self.total_price}€" + + +class OrderItem(models.Model): + """ + Individual item within an order + """ + + order = models.ForeignKey(Order, on_delete=models.CASCADE) + flavor = models.ForeignKey(Flavor, on_delete=models.CASCADE) + quantity = models.IntegerField(validators=[MinValueValidator(1)]) + + def __str__(self): + return f"{self.quantity} scoop(s) {self.flavor.get_name_display()}" + + @property + def item_price(self): + """Calculate price for this item (quantity * 2€)""" + return self.quantity * 2 + + +class AdminSettings(models.Model): + """ + Admin configuration settings + """ + + admin_email = models.EmailField(default="admin@nalo.com") + low_stock_threshold = models.IntegerField(default=5) + + class Meta: + verbose_name = "Admin Configuration" + verbose_name_plural = "Admin Configurations" + + def save(self, *args, **kwargs): + # Ensure only one instance exists + self.pk = 1 + super().save(*args, **kwargs) + + @classmethod + def get_default_settings(cls): + """Get or create default admin settings""" + settings, created = cls.objects.get_or_create(pk=1) + return settings diff --git a/ice_cream/serializers.py b/ice_cream/serializers.py new file mode 100644 index 0000000..4f83a34 --- /dev/null +++ b/ice_cream/serializers.py @@ -0,0 +1,79 @@ +from rest_framework import serializers + +from .models import Flavor, Order, OrderItem + + +class FlavorSerializer(serializers.ModelSerializer): + """ + Serializer for ice cream flavors + """ + + display_name = serializers.CharField(source="get_name_display", read_only=True) + is_empty = serializers.BooleanField(read_only=True) + fill_rate = serializers.FloatField(read_only=True) + + class Meta: + model = Flavor + fields = ["id", "name", "display_name", "stock", "is_empty", "fill_rate"] + + +class OrderItemSerializer(serializers.ModelSerializer): + """ + Serializer for order items + """ + + flavor_name = serializers.CharField( + source="flavor.get_name_display", read_only=True + ) + flavor_code = serializers.CharField(source="flavor.name", read_only=True) + item_price = serializers.FloatField(read_only=True) + + class Meta: + model = OrderItem + fields = ["flavor_name", "flavor_code", "quantity", "item_price"] + + +class OrderDetailSerializer(serializers.ModelSerializer): + """ + Serializer for detailed order information + """ + + items = OrderItemSerializer(source="orderitem_set", many=True, read_only=True) + total_scoops = serializers.IntegerField(read_only=True) + + class Meta: + model = Order + fields = ["order_code", "total_price", "total_scoops", "created_at", "items"] + + +class OrderItemCreateSerializer(serializers.Serializer): + """ + Serializer for creating order items + """ + + flavor_id = serializers.IntegerField() + quantity = serializers.IntegerField(min_value=1) + + +class OrderCreateSerializer(serializers.Serializer): + """ + Serializer for creating new orders + """ + + items = OrderItemCreateSerializer(many=True) + + def validate_items(self, value): + """ + Validate that items list is not empty + """ + if not value: + raise serializers.ValidationError("At least one item is required") + return value + + +class RefillPotSerializer(serializers.Serializer): + """ + Serializer for pot refill requests + """ + + flavor_id = serializers.IntegerField() diff --git a/ice_cream/templates/ice_cream/admin.html b/ice_cream/templates/ice_cream/admin.html new file mode 100644 index 0000000..a2858a2 --- /dev/null +++ b/ice_cream/templates/ice_cream/admin.html @@ -0,0 +1,337 @@ + + + + + + Administration - Automate Nalo + + + + + +
+
+

Administration

+

Recettes, stocks et gestion de l'automate

+
+ + +
+
+
+

Recettes totales

+

{{ total_revenue }}€

+
+
+
+
+

Commandes

+

{{ total_orders }}

+
+
+
+
+

Parfums

+

{{ flavors.count }}

+
+
+
+
+

Pots vides

+

{{ empty_pots_count }}

+
+
+
+ + +
+
+

Taux de remplissage des pots

+
+
+
+ {% for flavor in flavors %} +
+
+
+
+
{{ flavor.get_name_display }}
+

{{ flavor.stock }}/40 boules

+
+ + {% if flavor.is_empty %} + 🔴 Vide + {% elif flavor.stock < 10 %} + 🟡 Faible + {% else %} + 🟢 OK + {% endif %} + +
+ +
+
+
+ +
+ {{ flavor.fill_rate|floatformat:1 }}% + +
+ + {% if flavor.is_empty %} +
+ Alerte : Stock épuisé! Email envoyé à l'admin. +
+ {% elif flavor.stock < 10 %} +
+ Attention : Stock faible, remplissage recommandé. +
+ {% endif %} +
+
+ {% endfor %} +
+
+
+ + +
+
+

Commandes récentes

+
+
+ {% if recent_orders %} +
+ + + + + + + + + + + + {% for order in recent_orders %} + + + + + + + + {% endfor %} + +
CodeDateBoulesPrixDétails
{{ order.order_code }}{{ order.created_at|date:"d/m/Y H:i" }}{{ order.total_scoops }}{{ order.total_price }}€ + + {% for item in order.orderitem_set.all %} + {{ item.quantity }}× {{ item.flavor.get_name_display }}{% if not forloop.last %}, {% endif %} + {% endfor %} + +
+
+ {% else %} +
+ +

Aucune commande pour le moment

+
+ {% endif %} +
+
+ + +
+ ← Retour accueil + 📖 API Docs + ⚙️ Django Admin +
+
+ + + + + \ No newline at end of file diff --git a/ice_cream/templates/ice_cream/index.html b/ice_cream/templates/ice_cream/index.html new file mode 100644 index 0000000..25babff --- /dev/null +++ b/ice_cream/templates/ice_cream/index.html @@ -0,0 +1,103 @@ + + + + + + Automate Nalo + + + + +
+
+

🍦 Automate de Glaces Nalo

+

Votre distributeur automatique de glaces artisanales

+
+ +
+
+
+
+
+ +
+
Commander
+

Créez votre glace personnalisée en sélectionnant vos parfums préférés parmi nos 5 variétés disponibles.

+ + Commander maintenant + +
+
+
+ +
+
+
+
+ +
+
Récupérer
+

Retrouvez votre commande en entrant votre code unique et visualisez votre glace avant de la récupérer.

+ + Récupérer ma commande + +
+
+
+ +
+
+
+
+ +
+
Administration
+

Consultez les statistiques de vente, gérez les stocks et surveillez le taux de remplissage des pots.

+ + Dashboard Admin + +
+
+
+
+ +
+
+
+
+
🍨 Nos parfums disponibles
+
+
Chocolat Orange
+
Cerise
+
Pistache
+
Vanille
+
Framboise
+
+
+

+ Prix: 2€ par boule • 40 boules par pot • Codes de commande uniques +

+
+
+
+
+ +
+ 📖 Documentation API + ⚙️ Admin Django +
+
+ + + + \ No newline at end of file diff --git a/ice_cream/templates/ice_cream/order.html b/ice_cream/templates/ice_cream/order.html new file mode 100644 index 0000000..f463e04 --- /dev/null +++ b/ice_cream/templates/ice_cream/order.html @@ -0,0 +1,192 @@ + + + + + + Commander - Automate Nalo + + + + +
+
+
+
+
+

Créer votre commande

+

Sélectionnez vos parfums et quantités

+
+
+ +
+ +
+ +
+
+
+

Total: 0

+

Prix: 2€ par boule

+
+
+ +
+
+
+ + +
+
+ + +
+
+
+ + + + + \ No newline at end of file diff --git a/ice_cream/templates/ice_cream/retrieve.html b/ice_cream/templates/ice_cream/retrieve.html new file mode 100644 index 0000000..2895222 --- /dev/null +++ b/ice_cream/templates/ice_cream/retrieve.html @@ -0,0 +1,170 @@ + + + + + + Récupérer - Automate Nalo + + + + +
+
+
+
+
+

Récupérer votre commande

+

Entrez votre code de commande

+
+
+ +
+
+ + +
Code à 8 caractères reçu lors de votre commande
+
+
+ +
+
+ + + +
+
+ + +
+
+
+ + + + + \ No newline at end of file diff --git a/ice_cream/tests.py b/ice_cream/tests.py new file mode 100644 index 0000000..92ed7e2 --- /dev/null +++ b/ice_cream/tests.py @@ -0,0 +1,502 @@ +import json + +from django.contrib.auth.models import User +from django.test import Client, TestCase +from django.urls import reverse + +from rest_framework import status +from rest_framework.test import APITestCase + +from .models import AdminSettings, Flavor, Order, OrderItem + + +class FlavorModelTest(TestCase): + """Test cases for the Flavor model""" + + def setUp(self): + """Set up test data""" + self.flavor = Flavor.objects.create(name="vanilla", stock=40) + + def test_flavor_creation(self): + """Test flavor creation with default values""" + self.assertEqual(self.flavor.stock, 40) + self.assertEqual(self.flavor.get_name_display(), "Vanille") + self.assertFalse(self.flavor.is_empty) + self.assertEqual(self.flavor.fill_rate, 100.0) + + def test_consume_scoops_success(self): + """Test successful scoop consumption""" + result = self.flavor.consume_scoops(10) + self.assertTrue(result) + self.assertEqual(self.flavor.stock, 30) + + def test_consume_scoops_insufficient_stock(self): + """Test scoop consumption with insufficient stock""" + result = self.flavor.consume_scoops(45) + self.assertFalse(result) + self.assertEqual(self.flavor.stock, 40) # Stock unchanged + + def test_refill_pot(self): + """Test pot refilling functionality""" + self.flavor.stock = 5 + self.flavor.save() + + self.flavor.refill_pot() + self.assertEqual(self.flavor.stock, 40) + + def test_empty_pot_properties(self): + """Test empty pot detection""" + self.flavor.stock = 0 + self.flavor.save() + + self.assertTrue(self.flavor.is_empty) + self.assertEqual(self.flavor.fill_rate, 0.0) + + +class OrderModelTest(TestCase): + """Test cases for the Order model""" + + def setUp(self): + """Set up test data""" + self.flavor1 = Flavor.objects.create(name="vanilla", stock=40) + self.flavor2 = Flavor.objects.create(name="chocolate_orange", stock=40) + + def test_order_creation_with_unique_code(self): + """Test order creation generates unique code""" + order = Order.objects.create(total_price=10.0) + + self.assertIsNotNone(order.order_code) + self.assertEqual(len(order.order_code), 8) + self.assertEqual(order.total_price, 10.0) + + def test_order_code_uniqueness(self): + """Test that order codes are unique""" + order1 = Order.objects.create(total_price=10.0) + order2 = Order.objects.create(total_price=20.0) + + self.assertNotEqual(order1.order_code, order2.order_code) + + def test_order_with_items(self): + """Test order with multiple items""" + order = Order.objects.create(total_price=8.0) + + OrderItem.objects.create(order=order, flavor=self.flavor1, quantity=2) + OrderItem.objects.create(order=order, flavor=self.flavor2, quantity=2) + + self.assertEqual(order.total_scoops, 4) + self.assertEqual(order.orderitem_set.count(), 2) + + +class OrderItemModelTest(TestCase): + """Test cases for the OrderItem model""" + + def setUp(self): + """Set up test data""" + self.flavor = Flavor.objects.create(name="vanilla", stock=40) + self.order = Order.objects.create(total_price=6.0) + + def test_order_item_creation(self): + """Test order item creation and price calculation""" + item = OrderItem.objects.create( + order=self.order, flavor=self.flavor, quantity=3 + ) + + self.assertEqual(item.quantity, 3) + self.assertEqual(item.item_price, 6) # 3 scoops * 2€ + self.assertEqual(str(item), "3 scoop(s) Vanille") + + +class OrderAPITest(APITestCase): + """Test cases for the Order API endpoints""" + + def setUp(self): + """Set up test data""" + self.flavor1 = Flavor.objects.create(name="vanilla", stock=40) + self.flavor2 = Flavor.objects.create(name="chocolate_orange", stock=5) + self.flavor3 = Flavor.objects.create(name="pistachio", stock=0) + + def test_create_order_success(self): + """Test successful order creation via API""" + order_data = { + "items": [ + {"flavor_id": self.flavor1.id, "quantity": 3}, + {"flavor_id": self.flavor2.id, "quantity": 2}, + ] + } + + response = self.client.post( + reverse("ice_cream:api_create_order"), data=order_data, format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + + self.assertTrue(data["success"]) + self.assertEqual(data["total_price"], 10.0) # 5 scoops * 2€ + self.assertIn("order_code", data) + + # Verify stock was updated + self.flavor1.refresh_from_db() + self.flavor2.refresh_from_db() + self.assertEqual(self.flavor1.stock, 37) + self.assertEqual(self.flavor2.stock, 3) + + def test_create_order_insufficient_stock(self): + """Test order creation with insufficient stock""" + order_data = { + "items": [ + {"flavor_id": self.flavor2.id, "quantity": 10} # More than 5 in stock + ] + } + + response = self.client.post( + reverse("ice_cream:api_create_order"), data=order_data, format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + data = response.json() + self.assertIn("Stock insuffisant", data["error"]) + + def test_create_order_empty_pot(self): + """Test order creation with empty pot""" + order_data = { + "items": [{"flavor_id": self.flavor3.id, "quantity": 1}] # Empty pot + } + + response = self.client.post( + reverse("ice_cream:api_create_order"), data=order_data, format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + data = response.json() + self.assertIn("Stock insuffisant", data["error"]) + + def test_create_order_invalid_quantity(self): + """Test order creation with invalid quantity""" + order_data = {"items": [{"flavor_id": self.flavor1.id, "quantity": 0}]} + + response = self.client.post( + reverse("ice_cream:api_create_order"), data=order_data, format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + data = response.json() + self.assertIn("Quantité invalide", data["error"]) + + def test_create_order_nonexistent_flavor(self): + """Test order creation with non-existent flavor""" + order_data = {"items": [{"flavor_id": 999, "quantity": 1}]} + + response = self.client.post( + reverse("ice_cream:api_create_order"), data=order_data, format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + data = response.json() + self.assertIn("Parfum inexistant", data["error"]) + + def test_get_order_success(self): + """Test successful order retrieval""" + # Create an order first + order = Order.objects.create(total_price=6.0) + OrderItem.objects.create(order=order, flavor=self.flavor1, quantity=3) + + response = self.client.get( + reverse("ice_cream:api_get_order", args=[order.order_code]) + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + + self.assertTrue(data["success"]) + self.assertEqual(data["order"]["code"], order.order_code) + self.assertEqual(data["order"]["total_price"], 6.0) + self.assertEqual(data["order"]["total_scoops"], 3) + self.assertEqual(len(data["order"]["items"]), 1) + + def test_get_order_not_found(self): + """Test retrieval of non-existent order""" + response = self.client.get( + reverse("ice_cream:api_get_order", args=["NOTFOUND"]) + ) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + data = response.json() + self.assertIn("non trouvée", data["error"]) + + def test_get_flavors_api(self): + """Test flavors API endpoint""" + response = self.client.get(reverse("ice_cream:api_get_flavors")) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + + # FIX: Vérifier la structure correcte de la réponse + self.assertTrue(data["success"]) + self.assertIn("flavors", data) + self.assertEqual(len(data["flavors"]), 3) + + # Vérifier la structure d'un parfum + first_flavor = data["flavors"][0] + self.assertIn("id", first_flavor) + self.assertIn("name", first_flavor) + self.assertIn("display_name", first_flavor) + self.assertIn("stock", first_flavor) + + +class RefillAPITest(APITestCase): + """Test cases for the pot refill API""" + + def setUp(self): + """Set up test data""" + self.flavor = Flavor.objects.create(name="vanilla", stock=10) + + def test_refill_pot_success(self): + """Test successful pot refilling""" + refill_data = {"flavor_id": self.flavor.id} + + response = self.client.post( + reverse("ice_cream:api_refill_pot"), data=refill_data, format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + + self.assertTrue(data["success"]) + self.assertEqual(data["new_stock"], 40) + + # Verify in database + self.flavor.refresh_from_db() + self.assertEqual(self.flavor.stock, 40) + + def test_refill_nonexistent_flavor(self): + """Test refilling non-existent flavor""" + refill_data = {"flavor_id": 999} + + response = self.client.post( + reverse("ice_cream:api_refill_pot"), data=refill_data, format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + +class WebViewsTest(TestCase): + """Test cases for web views""" + + def setUp(self): + """Set up test data""" + self.client = Client() + Flavor.objects.create(name="vanilla", stock=40) + Flavor.objects.create(name="chocolate_orange", stock=20) + + def test_index_page_renders(self): + """Test that index page renders correctly""" + response = self.client.get(reverse("ice_cream:index")) + self.assertEqual(response.status_code, 200) + # FIX: Chercher le bon texte dans le template + self.assertContains(response, "Automate de Glaces Nalo") + + def test_order_page_displays_flavors(self): + """Test that order page displays available flavors""" + response = self.client.get(reverse("ice_cream:order")) + self.assertEqual(response.status_code, 200) + # FIX: Vérifier que la page charge correctement + self.assertContains(response, "Commander") + + def test_retrieve_page_renders(self): + """Test that retrieve page renders correctly""" + response = self.client.get(reverse("ice_cream:retrieve")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Récupérer votre commande") + + def test_admin_page_shows_statistics(self): + """Test that admin page shows statistics""" + # FIX: Utiliser le bon nom d'URL + response = self.client.get(reverse("ice_cream:admin_dashboard")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Administration") + self.assertContains(response, "Recettes") + + +class AdminSettingsTest(TestCase): + """Test cases for admin settings""" + + def test_admin_settings_singleton(self): + """Test that only one admin settings instance can exist""" + # FIX: Utiliser get_or_create au lieu de create + settings1, created1 = AdminSettings.objects.get_or_create( + pk=1, defaults={"admin_email": "test1@nalo.com"} + ) + + # Essayer de créer un autre avec le même pk devrait retourner le même + settings2, created2 = AdminSettings.objects.get_or_create( + pk=1, defaults={"admin_email": "test2@nalo.com"} + ) + + # Should be the same instance + self.assertEqual(settings1.pk, settings2.pk) + self.assertFalse(created2) # Should not have created a new one + + def test_get_default_settings(self): + """Test getting default admin settings""" + settings = AdminSettings.get_default_settings() + + self.assertIsInstance(settings, AdminSettings) + self.assertEqual(settings.admin_email, "admin@nalo.com") + self.assertEqual(settings.low_stock_threshold, 5) + + +class IntegrationTest(APITestCase): + """Integration tests for complete user flows""" + + def setUp(self): + """Set up test data""" + # Create all flavors + self.flavors = [] + flavor_names = [ + "vanilla", + "chocolate_orange", + "cherry", + "pistachio", + "raspberry", + ] + + for name in flavor_names: + flavor = Flavor.objects.create(name=name, stock=40) + self.flavors.append(flavor) + + def test_complete_order_flow(self): + """Test complete flow: create order -> retrieve order""" + # Step 1: Create an order + order_data = { + "items": [ + {"flavor_id": self.flavors[0].id, "quantity": 3}, # Vanilla + {"flavor_id": self.flavors[1].id, "quantity": 2}, # Chocolate Orange + ] + } + + create_response = self.client.post( + reverse("ice_cream:api_create_order"), data=order_data, format="json" + ) + + self.assertEqual(create_response.status_code, status.HTTP_200_OK) + order_data = create_response.json() + order_code = order_data["order_code"] + + # Step 2: Retrieve the order + retrieve_response = self.client.get( + reverse("ice_cream:api_get_order", args=[order_code]) + ) + + self.assertEqual(retrieve_response.status_code, status.HTTP_200_OK) + retrieve_data = retrieve_response.json() + + # Verify order details + self.assertEqual(retrieve_data["order"]["total_scoops"], 5) + self.assertEqual(retrieve_data["order"]["total_price"], 10.0) + self.assertEqual(len(retrieve_data["order"]["items"]), 2) + + def test_stock_depletion_and_refill_flow(self): + """Test stock depletion and refill workflow""" + flavor = self.flavors[0] # Vanilla + + # Reduce stock to near empty + flavor.stock = 2 + flavor.save() + + # Create order that empties the stock + order_data = {"items": [{"flavor_id": flavor.id, "quantity": 2}]} + + response = self.client.post( + reverse("ice_cream:api_create_order"), data=order_data, format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Verify stock is empty + flavor.refresh_from_db() + self.assertEqual(flavor.stock, 0) + self.assertTrue(flavor.is_empty) + + # Try to order from empty stock (should fail) + order_data = {"items": [{"flavor_id": flavor.id, "quantity": 1}]} + + response = self.client.post( + reverse("ice_cream:api_create_order"), data=order_data, format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + # Refill the pot + refill_data = {"flavor_id": flavor.id} + response = self.client.post( + reverse("ice_cream:api_refill_pot"), data=refill_data, format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Verify stock is refilled + flavor.refresh_from_db() + self.assertEqual(flavor.stock, 40) + self.assertFalse(flavor.is_empty) + + # Now order should work again + response = self.client.post( + reverse("ice_cream:api_create_order"), data=order_data, format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_concurrent_orders_stock_consistency(self): + """Test that concurrent orders maintain stock consistency""" + flavor = self.flavors[0] + flavor.stock = 5 + flavor.save() + + # Simulate two concurrent orders for the same flavor + order_data_1 = {"items": [{"flavor_id": flavor.id, "quantity": 3}]} + order_data_2 = {"items": [{"flavor_id": flavor.id, "quantity": 3}]} + + # First order should succeed + response1 = self.client.post( + reverse("ice_cream:api_create_order"), data=order_data_1, format="json" + ) + self.assertEqual(response1.status_code, status.HTTP_200_OK) + + # Second order should fail due to insufficient stock + response2 = self.client.post( + reverse("ice_cream:api_create_order"), data=order_data_2, format="json" + ) + self.assertEqual(response2.status_code, status.HTTP_400_BAD_REQUEST) + + # Verify final stock + flavor.refresh_from_db() + self.assertEqual(flavor.stock, 2) + + +class APIDocumentationTest(TestCase): + """Test cases for API documentation endpoints""" + + def test_swagger_ui_accessible(self): + """Test that Swagger UI is accessible""" + response = self.client.get(reverse("ice_cream:schema-swagger-ui")) + self.assertEqual(response.status_code, 200) + + def test_redoc_accessible(self): + """Test that ReDoc is accessible""" + response = self.client.get(reverse("ice_cream:schema-redoc")) + self.assertEqual(response.status_code, 200) + + def test_openapi_schema_accessible(self): + """Test that OpenAPI schema is accessible""" + response = self.client.get(reverse("ice_cream:schema-json")) + self.assertEqual(response.status_code, 200) + # FIX: Le schema peut être en YAML ou JSON + self.assertIn( + response["content-type"], + [ + "application/openapi+json", + "application/yaml; charset=utf-8", + "application/json", + ], + ) diff --git a/ice_cream/urls.py b/ice_cream/urls.py new file mode 100644 index 0000000..64c56dd --- /dev/null +++ b/ice_cream/urls.py @@ -0,0 +1,59 @@ +from django.urls import path + +from drf_yasg import openapi +from drf_yasg.views import get_schema_view +from rest_framework import permissions + +from . import views + +# Swagger configuration +schema_view = get_schema_view( + openapi.Info( + title="Nalo Ice Cream Automate API", + default_version="v1", + description=""" + API for the Nalo Ice Cream Automate system. + + Features: + - Flavor and stock management + - Order creation and retrieval + - Pot refilling + - Stock alerts + + Each scoop costs 2€, each pot contains 40 scoops maximum. + """, + contact=openapi.Contact(email="tech@nalo.com"), + license=openapi.License(name="MIT License"), + ), + public=True, + permission_classes=[permissions.AllowAny], +) + +app_name = "ice_cream" + +urlpatterns = [ + # Web pages (URLs en anglais, interface en français) + path("", views.index_view, name="index"), + path( + "order/", views.order_page_view, name="order" + ), # /order/ au lieu de /commander/ + path( + "retrieve/", views.retrieve_order_page_view, name="retrieve" + ), # /retrieve/ au lieu de /recuperer/ + path("admin-dashboard/", views.admin_dashboard_view, name="admin_dashboard"), + # API endpoints (URLs en anglais) + path("api/flavors/", views.get_flavors_api, name="api_get_flavors"), + path("api/orders/", views.create_order_api, name="api_create_order"), + path("api/orders//", views.get_order_api, name="api_get_order"), + path("api/refill-pot/", views.refill_pot_api, name="api_refill_pot"), + # API Documentation + path( + "api/docs/", + schema_view.with_ui("swagger", cache_timeout=0), + name="schema-swagger-ui", + ), + path( + "api/redoc/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc" + ), + path("api/schema/", schema_view.without_ui(cache_timeout=0), name="schema-json"), +] diff --git a/ice_cream/views.py b/ice_cream/views.py new file mode 100644 index 0000000..7d0fdca --- /dev/null +++ b/ice_cream/views.py @@ -0,0 +1,310 @@ +import json + +from django.db import transaction +from django.http import JsonResponse +from django.shortcuts import get_object_or_404, render +from django.utils import timezone +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods + +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +from .models import Flavor, Order, OrderItem + + +def index_view(request): + """Homepage with navigation to main functions""" + return render(request, "ice_cream/index.html") + + +def order_page_view(request): + """Order creation page""" + return render(request, "ice_cream/order.html") + + +def retrieve_order_page_view(request): + """Order retrieval page""" + return render(request, "ice_cream/retrieve.html") + + +def admin_dashboard_view(request): + """Admin dashboard - revenue and fill rates""" + flavors = Flavor.objects.all() + recent_orders = Order.objects.order_by("-created_at")[:10] + + # Calculate statistics + total_revenue = sum(order.total_price for order in Order.objects.all()) + total_orders = Order.objects.count() + empty_pots_count = flavors.filter(stock=0).count() + + context = { + "flavors": flavors, + "recent_orders": recent_orders, + "total_revenue": total_revenue, + "total_orders": total_orders, + "empty_pots_count": empty_pots_count, + } + return render(request, "ice_cream/admin.html", context) + + +# === API ENDPOINTS === + + +@swagger_auto_schema( + method="get", + operation_description="Get all flavors with their stock information", + responses={200: "List of flavors with stock levels"}, +) +@api_view(["GET"]) +@permission_classes([AllowAny]) +def get_flavors_api(request): + """API: Get all available flavors with stock information""" + flavors = Flavor.objects.all() + flavors_data = [] + + for flavor in flavors: + flavors_data.append( + { + "id": flavor.id, + "name": flavor.name, + "display_name": flavor.get_name_display(), + "stock": flavor.stock, + "is_empty": flavor.is_empty, + "fill_rate": flavor.fill_rate, + } + ) + + return Response( + {"success": True, "flavors": flavors_data, "total_flavors": len(flavors_data)} + ) + + +@swagger_auto_schema( + method="post", + operation_description="Create a new ice cream order", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + required=["items"], + properties={ + "items": openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "flavor_id": openapi.Schema( + type=openapi.TYPE_INTEGER, description="Flavor ID" + ), + "quantity": openapi.Schema( + type=openapi.TYPE_INTEGER, description="Number of scoops" + ), + }, + ), + ) + }, + example={ + "items": [{"flavor_id": 1, "quantity": 3}, {"flavor_id": 2, "quantity": 2}] + }, + ), + responses={ + 200: "Order created successfully", + 400: "Validation error or insufficient stock", + }, +) +@api_view(["POST"]) +@permission_classes([AllowAny]) +def create_order_api(request): + """API: Create a new ice cream order""" + try: + order_items = request.data.get("items", []) + + if not order_items: + return Response( + {"error": "Aucun article dans la commande"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + with transaction.atomic(): + total_price = 0 + items_to_create = [] + + for item in order_items: + flavor_id = item.get("flavor_id") + quantity = item.get("quantity", 0) + + if quantity <= 0: + return Response( + {"error": "Quantité invalide"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + flavor = Flavor.objects.select_for_update().get(id=flavor_id) + except Flavor.DoesNotExist: + return Response( + {"error": f"Parfum inexistant: {flavor_id}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if flavor.stock < quantity: + return Response( + { + "error": f"Stock insuffisant pour {flavor.get_name_display()}. Stock disponible: {flavor.stock}" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + items_to_create.append( + {"flavor": flavor, "quantity": quantity, "price": quantity * 2} + ) + total_price += quantity * 2 + + # Create order + order = Order.objects.create(total_price=total_price) + + # Create items and update stock + for item_data in items_to_create: + OrderItem.objects.create( + order=order, + flavor=item_data["flavor"], + quantity=item_data["quantity"], + ) + + # Update stock + flavor = item_data["flavor"] + flavor.consume_scoops(item_data["quantity"]) + + # Check if pot is empty and send notification + if flavor.is_empty: + _send_empty_pot_notification(flavor) + + return Response( + { + "success": True, + "order_code": order.order_code, + "total_price": float(order.total_price), + "total_scoops": order.total_scoops, + "message": f"Commande créée avec succès!", + } + ) + + except Exception as e: + return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@swagger_auto_schema( + method="get", + operation_description="Retrieve an order by its unique code", + responses={200: "Order found", 404: "Order not found"}, +) +@api_view(["GET"]) +@permission_classes([AllowAny]) +def get_order_api(request, order_code): + """API: Retrieve an order by its unique code""" + try: + order = Order.objects.get(order_code=order_code.upper()) + order_items = [] + + for item in order.orderitem_set.all(): + order_items.append( + { + "flavor": item.flavor.get_name_display(), + "flavor_code": item.flavor.name, + "quantity": item.quantity, + "price": float(item.item_price), + } + ) + + return Response( + { + "success": True, + "order": { + "code": order.order_code, + "total_price": float(order.total_price), + "total_scoops": order.total_scoops, + "created_at": order.created_at.strftime("%d/%m/%Y %H:%M"), + "items": order_items, + }, + } + ) + except Order.DoesNotExist: + return Response( + {"error": "Commande non trouvée"}, status=status.HTTP_404_NOT_FOUND + ) + + +@swagger_auto_schema( + method="post", + operation_description="Refill an ice cream pot to 40 scoops", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + required=["flavor_id"], + properties={ + "flavor_id": openapi.Schema( + type=openapi.TYPE_INTEGER, description="Flavor ID to refill" + ) + }, + example={"flavor_id": 1}, + ), + responses={200: "Pot refilled successfully", 404: "Flavor not found"}, +) +@api_view(["POST"]) +@permission_classes([AllowAny]) +def refill_pot_api(request): + """API: Refill an ice cream pot + send admin email (print simulation)""" + try: + flavor_id = request.data.get("flavor_id") + + flavor = Flavor.objects.get(id=flavor_id) + old_stock = flavor.stock + flavor.refill_pot() + + # Email notification (simulated by print) + _send_refill_notification(flavor, old_stock) + + return Response( + { + "success": True, + "message": f"Pot de {flavor.get_name_display()} rempli à 40 boules", + "old_stock": old_stock, + "new_stock": flavor.stock, + } + ) + except Flavor.DoesNotExist: + return Response( + {"error": "Parfum non trouvé"}, status=status.HTTP_404_NOT_FOUND + ) + except Exception as e: + return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +def _send_empty_pot_notification(flavor): + """ + Send notification when a pot becomes empty + (Simulated by print for testing) + """ + print(f"🚨 EMPTY STOCK ALERT!") + print(f"📧 Automatic email sent to administrator:") + print(f" Subject: Stock depleted - {flavor.get_name_display()}") + print(f" Message: The {flavor.get_name_display()} pot is now empty.") + print(f" Action required: Immediate restocking needed.") + print(f" Timestamp: {timezone.now().strftime('%d/%m/%Y %H:%M:%S')}") + print("-" * 50) + + +def _send_refill_notification(flavor, old_stock): + """ + Send notification when a pot is refilled + (Simulated by print for testing) + """ + print(f"✅ POT REFILL COMPLETED") + print(f"📧 Confirmation email sent to administrator:") + print(f" Subject: Pot refilled - {flavor.get_name_display()}") + print(f" Message: The {flavor.get_name_display()} pot has been refilled.") + print(f" Details: {old_stock} → 40 scoops (+{40 - old_stock} scoops)") + print(f" Timestamp: {timezone.now().strftime('%d/%m/%Y %H:%M:%S')}") + print("-" * 50) diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..fc1b7ca --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "nalo_automate.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/nalo_automate/__init__.py b/nalo_automate/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nalo_automate/asgi.py b/nalo_automate/asgi.py new file mode 100644 index 0000000..940c6b3 --- /dev/null +++ b/nalo_automate/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for nalo_automate project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "nalo_automate.settings") + +application = get_asgi_application() diff --git a/nalo_automate/settings.py b/nalo_automate/settings.py new file mode 100644 index 0000000..f958233 --- /dev/null +++ b/nalo_automate/settings.py @@ -0,0 +1,174 @@ +""" +Django settings for nalo_automate project. + +Generated by 'django-admin startproject' using Django 5.2.2. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.2/ref/settings/ +""" + +import os +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.environ.get( + "SECRET_KEY", "django-insecure-test-key-for-nalo-automate-2024" +) + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.environ.get("DEBUG", "True").lower() == "true" + +ALLOWED_HOSTS = ["localhost", "127.0.0.1", "0.0.0.0"] + +# Application definition +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + # Third party apps + "rest_framework", + "drf_yasg", + "corsheaders", + # Local apps + "ice_cream", +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "nalo_automate.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "nalo_automate.wsgi.application" + +# Database - SQLite for simplicity +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + +# Password validation +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +# REST Framework configuration +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.AllowAny", + ], + "DEFAULT_RENDERER_CLASSES": [ + "rest_framework.renderers.JSONRenderer", + ], + "DEFAULT_PARSER_CLASSES": [ + "rest_framework.parsers.JSONParser", + ], +} + +# Swagger settings +SWAGGER_SETTINGS = { + "SECURITY_DEFINITIONS": { + "Basic": {"type": "basic"}, + "Bearer": {"type": "apiKey", "name": "Authorization", "in": "header"}, + }, + "USE_SESSION_AUTH": False, + "JSON_EDITOR": True, + "SUPPORTED_SUBMIT_METHODS": ["get", "post", "put", "delete", "patch"], + "OPERATIONS_SORTER": "alpha", + "TAGS_SORTER": "alpha", + "DOC_EXPANSION": "none", + "DEEP_LINKING": True, + "SHOW_EXTENSIONS": True, + "DEFAULT_MODEL_RENDERING": "example", +} + +# CORS settings (for API access from different origins) +CORS_ALLOWED_ORIGINS = [ + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://localhost:8080", + "http://127.0.0.1:8080", +] + +CORS_ALLOW_ALL_ORIGINS = DEBUG + +# Internationalization +LANGUAGE_CODE = "fr-fr" +TIME_ZONE = "Europe/Paris" +USE_I18N = True +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "staticfiles" + +# Media files +MEDIA_URL = "/media/" +MEDIA_ROOT = BASE_DIR / "media" + +# Default primary key field type +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# Email configuration (for notifications) +EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +DEFAULT_FROM_EMAIL = "nalo@example.com" + +# Logging configuration +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "root": { + "handlers": ["console"], + }, +} diff --git a/nalo_automate/urls.py b/nalo_automate/urls.py new file mode 100644 index 0000000..bb95565 --- /dev/null +++ b/nalo_automate/urls.py @@ -0,0 +1,24 @@ +""" +URL configuration for nalo_automate project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + path("", include("ice_cream.urls")), # ⭐ Inclut les URLs de l'app +] diff --git a/nalo_automate/wsgi.py b/nalo_automate/wsgi.py new file mode 100644 index 0000000..e9c3352 --- /dev/null +++ b/nalo_automate/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for nalo_automate project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "nalo_automate.settings") + +application = get_wsgi_application() diff --git a/pistachio.jpg b/pistachio.jpg deleted file mode 100644 index b6f31e1..0000000 Binary files a/pistachio.jpg and /dev/null differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1bbd6a0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,58 @@ +[tool.black] +line-length = 88 +target-version = ['py38'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | venv + | _build + | buck-out + | build + | dist + | migrations +)/ +''' + +[tool.isort] +profile = "black" +multi_line_output = 3 +line_length = 88 +known_django = "django" +known_first_party = "ice_cream,nalo_automate" +sections = ["FUTURE", "STDLIB", "DJANGO", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] + +[tool.coverage.run] +source = ["."] +omit = [ + "*/venv/*", + "*/migrations/*", + "manage.py", + "*/settings/*", + "*/tests/*", + "*/__pycache__/*" +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError" +] + +[tool.commitizen] +name = "cz_conventional_commits" +version = "0.1.0" +tag_format = "v$version" +version_files = [ + "pyproject.toml:version" +] +bump_message = "bump: version $current_version → $new_version" +update_changelog_on_bump = true \ No newline at end of file diff --git a/raspberry.jpg b/raspberry.jpg deleted file mode 100644 index 1154dd0..0000000 Binary files a/raspberry.jpg and /dev/null differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fdccf01 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,24 @@ +# requirements.txt (updated with dev tools) +# Core Django dependencies +Django>=4.2.0 +djangorestframework>=3.14.0 +drf-yasg>=1.21.0 +django-cors-headers>=4.0.0 +python-decouple>=3.8 + +# Development and code quality tools +black>=23.0.0 +isort>=5.12.0 +flake8>=6.0.0 +pylint>=2.17.0 +pylint-django>=2.5.0 +autopep8>=2.0.0 + +# Testing tools +coverage>=7.0.0 +factory-boy>=3.2.0 +pytest-django>=4.5.0 + +# Additional dev tools +pre-commit>=3.0.0 +bandit>=1.7.0 \ No newline at end of file diff --git a/vanilla.jpg b/vanilla.jpg deleted file mode 100644 index a1793c6..0000000 Binary files a/vanilla.jpg and /dev/null differ