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
+
+
+
+
+
+
+
+
+
+
+
+
+
Recettes totales
+ {{ total_revenue }}€
+
+
+
+
+
Commandes
+ {{ total_orders }}
+
+
+
+
+
Parfums
+ {{ flavors.count }}
+
+
+
+
+
Pots vides
+ {{ empty_pots_count }}
+
+
+
+
+
+
+
+
+
+ {% 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 %}
+
+
+
+
+
+
+
+
+ {% if recent_orders %}
+
+
+
+
+ | Code |
+ Date |
+ Boules |
+ Prix |
+ Détails |
+
+
+
+ {% for order in recent_orders %}
+
+ | {{ 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 %}
+
+ |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+
Aucune commande pour le moment
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
\ 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
+
+
+
+
+
+
+
+
+
+
+
+
\ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
\ 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