sandwitches 1.0.3__tar.gz → 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {sandwitches-1.0.3 → sandwitches-1.1.0}/PKG-INFO +3 -3
  2. {sandwitches-1.0.3 → sandwitches-1.1.0}/pyproject.toml +3 -3
  3. sandwitches-1.1.0/src/sandwitches/api.py +65 -0
  4. sandwitches-1.1.0/src/sandwitches/forms.py +89 -0
  5. sandwitches-1.1.0/src/sandwitches/migrations/0003_rating.py +57 -0
  6. {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/models.py +27 -0
  7. {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/settings.py +7 -1
  8. sandwitches-1.1.0/src/sandwitches/tasks.py +16 -0
  9. {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/templates/base_pico.html +2 -0
  10. sandwitches-1.1.0/src/sandwitches/templates/detail.html +104 -0
  11. sandwitches-1.1.0/src/sandwitches/templates/signup.html +50 -0
  12. {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/urls.py +5 -0
  13. sandwitches-1.1.0/src/sandwitches/views.py +121 -0
  14. sandwitches-1.0.3/src/sandwitches/forms.py +0 -60
  15. sandwitches-1.0.3/src/sandwitches/templates/detail.html +0 -44
  16. sandwitches-1.0.3/src/sandwitches/views.py +0 -58
  17. {sandwitches-1.0.3 → sandwitches-1.1.0}/README.md +0 -0
  18. {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/__init__.py +0 -0
  19. {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/admin.py +0 -0
  20. {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/asgi.py +0 -0
  21. {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/migrations/0001_initial.py +0 -0
  22. {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/migrations/0002_historicalrecipe.py +0 -0
  23. {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/migrations/__init__.py +0 -0
  24. {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/storage.py +0 -0
  25. {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/templates/base.html +0 -0
  26. {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/templates/form.html +0 -0
  27. {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/templates/index.html +0 -0
  28. {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/templates/setup.html +0 -0
  29. {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/templatetags/__init__.py +0 -0
  30. {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/templatetags/markdown_extras.py +0 -0
  31. {sandwitches-1.0.3 → sandwitches-1.1.0}/src/sandwitches/wsgi.py +0 -0
@@ -1,14 +1,14 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sandwitches
3
- Version: 1.0.3
3
+ Version: 1.1.0
4
4
  Summary: Add your description here
5
5
  Author: Martyn van Dijke
6
6
  Author-email: Martyn van Dijke <martijnvdijke600@gmail.com>
7
7
  Requires-Dist: django-debug-toolbar>=6.1.0
8
8
  Requires-Dist: django-filter>=25.2
9
+ Requires-Dist: django-ninja>=1.5.1
9
10
  Requires-Dist: django-simple-history>=3.10.1
10
- Requires-Dist: django>=5.2.7
11
- Requires-Dist: djangorestframework>=3.16.1
11
+ Requires-Dist: django>=6.0.0
12
12
  Requires-Dist: gunicorn>=23.0.0
13
13
  Requires-Dist: markdown>=3.10
14
14
  Requires-Dist: pillow>=12.0.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sandwitches"
3
- version = "1.0.3"
3
+ version = "1.1.0"
4
4
  description = "Add your description here"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -12,9 +12,9 @@ requires-python = ">=3.12"
12
12
  dependencies = [
13
13
  "django-debug-toolbar>=6.1.0",
14
14
  "django-filter>=25.2",
15
+ "django-ninja>=1.5.1",
15
16
  "django-simple-history>=3.10.1",
16
- "django>=5.2.7",
17
- "djangorestframework>=3.16.1",
17
+ "django>=6.0.0",
18
18
  "gunicorn>=23.0.0",
19
19
  "markdown>=3.10",
20
20
  "pillow>=12.0.0",
@@ -0,0 +1,65 @@
1
+ from ninja import NinjaAPI
2
+ from .models import Recipe
3
+
4
+ from ninja import ModelSchema
5
+ from ninja import Schema
6
+ from django.contrib.auth.models import User
7
+ from django.shortcuts import get_object_or_404
8
+ from datetime import date
9
+ import random
10
+
11
+
12
+ api = NinjaAPI()
13
+
14
+
15
+ class RecipeSchema(ModelSchema):
16
+ class Meta:
17
+ model = Recipe
18
+ fields = "__all__"
19
+
20
+
21
+ class UserSchema(ModelSchema):
22
+ class Meta:
23
+ model = User
24
+ exclude = ["password", "last_login", "user_permissions"]
25
+
26
+
27
+ class Error(Schema):
28
+ message: str
29
+
30
+
31
+ @api.get("v1/me", response={200: UserSchema, 403: Error})
32
+ def me(request):
33
+ if not request.user.is_authenticated:
34
+ return 403, {"message": "Please sign in first"}
35
+ return request.user
36
+
37
+
38
+ # TODO: enable recipe creation via API
39
+ # @api.post("v1/recipe", auth=django_auth, response=RecipeSchema)
40
+ # def create_recipe(request, payload: RecipeSchema):
41
+ # recipe = Recipe.objects.create(**payload.dict())
42
+ # return recipe
43
+
44
+
45
+ @api.get("v1/recipes", response=list[RecipeSchema])
46
+ def get_recipes(request):
47
+ recipes = Recipe.objects.all()
48
+ return recipes
49
+
50
+
51
+ @api.get("v1/recipes/{recipe_id}", response=RecipeSchema)
52
+ def get_recipe(request, recipe_id: int):
53
+ recipe = get_object_or_404(Recipe, id=recipe_id)
54
+ return recipe
55
+
56
+
57
+ @api.get("v1/recipe-of-the-day", response=RecipeSchema)
58
+ def get_recipe_of_the_day(request):
59
+ recipes = list(Recipe.objects.all())
60
+ if not recipes:
61
+ return None
62
+ today = date.today()
63
+ random.seed(today.toordinal())
64
+ recipe = random.choice(recipes)
65
+ return recipe
@@ -0,0 +1,89 @@
1
+ from django import forms
2
+ from django.contrib.auth import get_user_model
3
+ from django.contrib.auth.forms import UserCreationForm
4
+ from .models import Recipe
5
+
6
+ User = get_user_model()
7
+
8
+
9
+ class BaseUserFormMixin:
10
+ """Mixin to handle common password validation and user field processing."""
11
+
12
+ def clean_passwords(self, cleaned_data):
13
+ p1 = cleaned_data.get("password1")
14
+ p2 = cleaned_data.get("password2")
15
+ if p1 and p2 and p1 != p2:
16
+ raise forms.ValidationError("Passwords do not match.")
17
+ return cleaned_data
18
+
19
+ def _set_user_attributes(self, user, data):
20
+ """Helper to apply optional name fields."""
21
+ user.first_name = data.get("first_name", "")
22
+ user.last_name = data.get("last_name", "")
23
+ user.save()
24
+ return user
25
+
26
+
27
+ class AdminSetupForm(forms.ModelForm, BaseUserFormMixin):
28
+ password1 = forms.CharField(widget=forms.PasswordInput, label="Password")
29
+ password2 = forms.CharField(widget=forms.PasswordInput, label="Confirm Password")
30
+
31
+ class Meta:
32
+ model = User
33
+ fields = ("username", "first_name", "last_name", "email")
34
+
35
+ def clean(self):
36
+ cleaned_data = super().clean()
37
+ return self.clean_passwords(cleaned_data)
38
+
39
+ def save(self, commit=True):
40
+ data = self.cleaned_data
41
+ user = User.objects.create_superuser(
42
+ username=data["username"], email=data["email"], password=data["password1"]
43
+ )
44
+ return self._set_user_attributes(user, data)
45
+
46
+
47
+ class UserSignupForm(UserCreationForm, BaseUserFormMixin):
48
+ """Refactored Regular User Form inheriting from Django's UserCreationForm"""
49
+
50
+ class Meta(UserCreationForm.Meta):
51
+ model = User
52
+ fields = ("username", "first_name", "last_name", "email")
53
+
54
+ def clean(self):
55
+ return super().clean()
56
+
57
+ def save(self, commit=True):
58
+ user = super().save(commit=False)
59
+ user.is_superuser = False
60
+ user.is_staff = False
61
+ if commit:
62
+ user.save()
63
+ return user
64
+
65
+
66
+ class RecipeForm(forms.ModelForm):
67
+ class Meta:
68
+ model = Recipe
69
+ fields = [
70
+ "title",
71
+ "description",
72
+ "ingredients",
73
+ "instructions",
74
+ "image",
75
+ "tags",
76
+ ]
77
+ widgets = {
78
+ "tags": forms.TextInput(attrs={"placeholder": "tag1,tag2"}),
79
+ }
80
+
81
+
82
+ class RatingForm(forms.Form):
83
+ """Simple form for rating recipes (1-5)."""
84
+
85
+ score = forms.ChoiceField(
86
+ choices=[(str(i), str(i)) for i in range(1, 6)],
87
+ widget=forms.RadioSelect,
88
+ label="Your rating",
89
+ )
@@ -0,0 +1,57 @@
1
+ # Generated by Django 6.0 on 2025-12-21 21:17
2
+
3
+ import django.db.models.deletion
4
+ from django.conf import settings
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+ dependencies = [
10
+ ("sandwitches", "0002_historicalrecipe"),
11
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12
+ ]
13
+
14
+ operations = [
15
+ migrations.CreateModel(
16
+ name="Rating",
17
+ fields=[
18
+ (
19
+ "id",
20
+ models.BigAutoField(
21
+ auto_created=True,
22
+ primary_key=True,
23
+ serialize=False,
24
+ verbose_name="ID",
25
+ ),
26
+ ),
27
+ (
28
+ "score",
29
+ models.PositiveSmallIntegerField(
30
+ choices=[(1, 1), (2, 2), (3, 3), (4, 4), (5, 5)]
31
+ ),
32
+ ),
33
+ ("created_at", models.DateTimeField(auto_now_add=True)),
34
+ ("updated_at", models.DateTimeField(auto_now=True)),
35
+ (
36
+ "recipe",
37
+ models.ForeignKey(
38
+ on_delete=django.db.models.deletion.CASCADE,
39
+ related_name="ratings",
40
+ to="sandwitches.recipe",
41
+ ),
42
+ ),
43
+ (
44
+ "user",
45
+ models.ForeignKey(
46
+ on_delete=django.db.models.deletion.CASCADE,
47
+ related_name="ratings",
48
+ to=settings.AUTH_USER_MODEL,
49
+ ),
50
+ ),
51
+ ],
52
+ options={
53
+ "ordering": ("-updated_at",),
54
+ "unique_together": {("recipe", "user")},
55
+ },
56
+ ),
57
+ ]
@@ -3,9 +3,13 @@ from django.urls import reverse
3
3
  from django.utils.text import slugify
4
4
  from .storage import HashedFilenameStorage
5
5
  from simple_history.models import HistoricalRecords
6
+ from django.contrib.auth import get_user_model
7
+ from django.db.models import Avg
6
8
 
7
9
  hashed_storage = HashedFilenameStorage()
8
10
 
11
+ User = get_user_model()
12
+
9
13
 
10
14
  class Tag(models.Model):
11
15
  name = models.CharField(max_length=50, unique=True)
@@ -87,8 +91,31 @@ class Recipe(models.Model):
87
91
  self.tags.set(tags)
88
92
  return self.tags.all()
89
93
 
94
+ # add helper methods for ratings
95
+ def average_rating(self):
96
+ agg = self.ratings.aggregate(avg=Avg("score"))
97
+ return agg["avg"] or 0
98
+
99
+ def rating_count(self):
100
+ return self.ratings.count()
101
+
90
102
  def get_absolute_url(self):
91
103
  return reverse("recipe_detail", kwargs={"pk": self.pk, "slug": self.slug})
92
104
 
93
105
  def __str__(self):
94
106
  return self.title
107
+
108
+
109
+ class Rating(models.Model):
110
+ recipe = models.ForeignKey(Recipe, related_name="ratings", on_delete=models.CASCADE)
111
+ user = models.ForeignKey(User, related_name="ratings", on_delete=models.CASCADE)
112
+ score = models.PositiveSmallIntegerField(choices=[(i, i) for i in range(1, 6)])
113
+ created_at = models.DateTimeField(auto_now_add=True)
114
+ updated_at = models.DateTimeField(auto_now=True)
115
+
116
+ class Meta:
117
+ unique_together = ("recipe", "user")
118
+ ordering = ("-updated_at",)
119
+
120
+ def __str__(self):
121
+ return f"{self.recipe} — {self.score} by {self.user}"
@@ -19,11 +19,16 @@ DEBUG = bool(os.environ.get("DEBUG", default=0))
19
19
  ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "127.0.0.1").split(",")
20
20
  CSRF_TRUSTED_ORIGINS = os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",")
21
21
 
22
+ # RECAPTCHA_PROXY = {'http': 'http://127.0.0.1:8000', 'https': 'https://127.0.0.1:8000'}
23
+ # RECAPTCHA_PUBLIC_KEY = os.environ.get("RECAPTCHA_PUBLIC_KEY")
24
+ # RECAPTCHA_PRIVATE_KEY = os.environ.get("RECAPTCHA_PRIVATE_KEY")
25
+
22
26
  # Build paths inside the project like this: BASE_DIR / 'subdir'.
23
27
  BASE_DIR = Path(__file__).resolve().parent.parent
24
28
 
25
- # Application definition
29
+ TASKS = {"default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBackend"}}
26
30
 
31
+ # Application definition
27
32
  INSTALLED_APPS = [
28
33
  "django.contrib.admin",
29
34
  "django.contrib.auth",
@@ -33,6 +38,7 @@ INSTALLED_APPS = [
33
38
  "django.contrib.staticfiles",
34
39
  "sandwitches",
35
40
  "debug_toolbar",
41
+ # "django_recaptcha",
36
42
  "simple_history",
37
43
  ]
38
44
 
@@ -0,0 +1,16 @@
1
+ import logging
2
+ from django.core.mail import send_mail
3
+ from django.tasks import task
4
+
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ @task(takes_context=True, priority=2, queue_name="emails")
10
+ def email_users(context, emails, subject, message):
11
+ logger.debug(
12
+ f"Attempt {context.attempt} to send user email. Task result id: {context.task_result.id}."
13
+ )
14
+ return send_mail(
15
+ subject=subject, message=message, from_email=None, recipient_list=emails
16
+ )
@@ -168,11 +168,13 @@
168
168
  </li>
169
169
  </ul>
170
170
  <ul>
171
+ <li><a href="api/docs">Docs</a></li>
171
172
  {% if user.is_authenticated %}
172
173
  <li><a href="{% url 'admin:index' %}">Admin</a></li>
173
174
  <li><a href="{% url 'admin:logout' %}">Logout</a></li>
174
175
  {% else %}
175
176
  <li><a href="{% url 'admin:login' %}">Login</a></li>
177
+ <li><a href="{% url 'signup' %}">Sign up</a></li>
176
178
  {% endif %}
177
179
  </ul>
178
180
  </nav>
@@ -0,0 +1,104 @@
1
+ {% extends "base_pico.html" %}
2
+ {% block title %}{{ recipe.title }} — Sandwitch{% endblock %}
3
+
4
+ {% block content %}
5
+
6
+ {% load markdown_extras %}
7
+ <nav aria-label="breadcrumb" class="container">
8
+ <a href="{% url 'index' %}">&larr; Back to all</a>
9
+ </nav>
10
+
11
+ <div class="grid">
12
+ <article class="card">
13
+ <figure>
14
+ {% if recipe.image %}
15
+ <img src="{{ recipe.image.url }}" alt="{{ recipe.title }}">
16
+ {% endif %}
17
+ </figure>
18
+ <header>
19
+ {{ recipe.title }}
20
+ </header>
21
+ <h4>Description</h4>
22
+ {{ recipe.description|convert_markdown|safe|default:"No description yet." }}
23
+
24
+ <h4>Ingredients</h4>
25
+ {{ recipe.ingredients|convert_markdown|safe|default:"No ingredients listed." }}
26
+
27
+ <h4>Instructions</h4>
28
+ {{ recipe.instructions|convert_markdown|safe|default:"No instructions yet." }}
29
+
30
+ <div class="tags-row">
31
+ <div style="margin-top:12px;">
32
+ {% for tag in recipe.tags.all %}
33
+ <span class="tag">{{ tag.name }}</span>
34
+ {% endfor %}
35
+ </div>
36
+ </div>
37
+
38
+ <div style="margin-top:1rem;">
39
+ <h4>Rating</h4>
40
+ {% if rating_count %}
41
+ <p>Average: {{ avg_rating|floatformat:1 }} ({{ rating_count }} vote{% if rating_count %}s{% endif %})</p>
42
+ {% else %}
43
+ <p>No ratings yet.</p>
44
+ {% endif %}
45
+
46
+ {% if user.is_authenticated %}
47
+ {% if user_rating %}
48
+ <p>Your rating: {{ user_rating.score }}</p>
49
+ {% else %}
50
+ <form method="post" action="{% url 'recipe_rate' pk=recipe.pk %}">
51
+ {% csrf_token %}
52
+
53
+ <fieldset>
54
+ <legend>What would you rate this sandwich?</legend>
55
+
56
+ {% if rating_form.score.errors %}
57
+ <div class="card-panel" role="alert">
58
+ <ul>
59
+ {% for err in rating_form.score.errors %}
60
+ <li>{{ err }}</li>
61
+ {% endfor %}
62
+ </ul>
63
+ </div>
64
+ {% endif %}
65
+
66
+ <div class="row">
67
+ {% for i in "12345"|make_list %}
68
+ <div class="col-sm-2">
69
+ <label class="form-check">
70
+ <input
71
+ type="radio"
72
+ name="{{ rating_form.score.name }}"
73
+ id="rating-{{ i }}"
74
+ value="{{ i }}"
75
+ {% if user_rating and user_rating.score|stringformat:"s" == i %}
76
+ checked
77
+ {% elif rating_form.score.value == i %}
78
+ checked
79
+ {% endif %}
80
+ />
81
+ <span>{{ i }}</span>
82
+ </label>
83
+ </div>
84
+ {% endfor %}
85
+ </div>
86
+ </fieldset>
87
+ <p style="margin-top:0.5rem;">
88
+ <button type="submit">{% if user_rating %}Update{% else %}Rate{% endif %}</button>
89
+ </p>
90
+ </form>
91
+ {% endif %}
92
+ {% else %}
93
+ <p><a href="{% url 'admin:login' %}">Log in</a> to rate this recipe.</p>
94
+ {% endif %}
95
+ </div>
96
+
97
+ {% if user.is_authenticated and user.is_staff %}
98
+ <footer>
99
+ <a href="/admin/sandwitches/recipe/{{ recipe.pk }}/change/">Edit</a>
100
+ </footer>
101
+ {% endif %}
102
+ </article>
103
+ </div>
104
+ {% endblock %}
@@ -0,0 +1,50 @@
1
+ {% extends "base_pico.html" %}
2
+ {% load static %}
3
+ {% block title %}Sign Up{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="container" style="max-width:720px; margin:2rem auto;">
7
+ <article class="card">
8
+ <div class="card-body">
9
+ <h2>Sign up</h2>
10
+
11
+ <form method="post" novalidate>
12
+ {% csrf_token %}
13
+ {% if form.non_field_errors %}
14
+ <div class="card-panel" role="alert">
15
+ <ul>
16
+ {% for err in form.non_field_errors %}
17
+ <li>{{ err }}</li>
18
+ {% endfor %}
19
+ </ul>
20
+ </div>
21
+ {% endif %}
22
+
23
+ <label for="{{ form.username.id_for_label }}">Username</label>
24
+ {{ form.username }}
25
+
26
+ <label for="{{ form.email.id_for_label }}">Email (optional)</label>
27
+ {{ form.email }}
28
+
29
+ <label for="{{ form.first_name.id_for_label }}">First name</label>
30
+ {{ form.first_name }}
31
+
32
+ <label for="{{ form.last_name.id_for_label }}">Last name</label>
33
+ {{ form.last_name }}
34
+
35
+ <label for="{{ form.password1.id_for_label }}">Password</label>
36
+ {{ form.password1 }}
37
+
38
+ <label for="{{ form.password2.id_for_label }}">Confirm password</label>
39
+ {{ form.password2 }}
40
+
41
+ <p style="margin-top:1rem;">
42
+ <button type="submit">Sign Up</button>
43
+ <a class="contrast" href="{% url 'index' %}">Cancel</a>
44
+ </p>
45
+ </form>
46
+ </div>
47
+ </article>
48
+ </div>
49
+
50
+ {% endblock %}
@@ -18,6 +18,8 @@ Including another URLconf
18
18
  from django.contrib import admin
19
19
  from django.urls import path
20
20
  from . import views
21
+ from .api import api
22
+
21
23
 
22
24
  from django.conf import settings
23
25
  from django.conf.urls.static import static
@@ -31,6 +33,9 @@ urlpatterns = [
31
33
  path("admin/", admin.site.urls),
32
34
  path("recipes/<slug:slug>/", views.recipe_detail, name="recipe_detail"),
33
35
  path("setup/", views.setup, name="setup"),
36
+ path("api/", api.urls),
37
+ path("signup/", views.signup, name="signup"),
38
+ path("recipes/<int:pk>/rate/", views.recipe_rate, name="recipe_rate"),
34
39
  ]
35
40
 
36
41
  if "test" not in sys.argv or "PYTEST_VERSION" in os.environ:
@@ -0,0 +1,121 @@
1
+ from django.shortcuts import render, get_object_or_404, redirect
2
+ from django.urls import reverse
3
+ from django.contrib import messages
4
+ from django.contrib.auth import login
5
+ from django.contrib.auth import get_user_model
6
+ from django.contrib.auth.decorators import login_required
7
+
8
+ from .models import Recipe, Rating
9
+ from .forms import RecipeForm, AdminSetupForm, UserSignupForm, RatingForm
10
+
11
+ User = get_user_model()
12
+
13
+
14
+ def recipe_edit(request, pk):
15
+ recipe = get_object_or_404(Recipe, pk=pk)
16
+ if request.method == "POST":
17
+ form = RecipeForm(request.POST, request.FILES, instance=recipe)
18
+ if form.is_valid():
19
+ form.save()
20
+ return redirect("recipes:admin_list")
21
+ else:
22
+ form = RecipeForm(instance=recipe)
23
+ return render(request, "recipe_form.html", {"form": form, "recipe": recipe})
24
+
25
+
26
+ def recipe_detail(request, slug):
27
+ recipe = get_object_or_404(Recipe, slug=slug)
28
+ avg = recipe.average_rating()
29
+ count = recipe.rating_count()
30
+ user_rating = None
31
+ rating_form = None
32
+ if request.user.is_authenticated:
33
+ try:
34
+ user_rating = Rating.objects.get(recipe=recipe, user=request.user)
35
+ except Rating.DoesNotExist:
36
+ user_rating = None
37
+ # show form prefilled when possible
38
+ initial = {"score": str(user_rating.score)} if user_rating else None
39
+ rating_form = RatingForm(initial=initial)
40
+ return render(
41
+ request,
42
+ "detail.html",
43
+ {
44
+ "recipe": recipe,
45
+ "avg_rating": avg,
46
+ "rating_count": count,
47
+ "user_rating": user_rating,
48
+ "rating_form": rating_form,
49
+ },
50
+ )
51
+
52
+
53
+ @login_required
54
+ def recipe_rate(request, pk):
55
+ """
56
+ Create or update a rating for the given recipe by the logged-in user.
57
+ """
58
+ recipe = get_object_or_404(Recipe, pk=pk)
59
+ if request.method != "POST":
60
+ return redirect("recipe_detail", slug=recipe.slug)
61
+
62
+ form = RatingForm(request.POST)
63
+ if form.is_valid():
64
+ score = int(form.cleaned_data["score"])
65
+ Rating.objects.update_or_create(
66
+ recipe=recipe, user=request.user, defaults={"score": score}
67
+ )
68
+ messages.success(request, "Your rating has been saved.")
69
+ else:
70
+ messages.error(request, "Could not save rating.")
71
+ return redirect("recipe_detail", slug=recipe.slug)
72
+
73
+
74
+ def index(request):
75
+ if not User.objects.filter(is_superuser=True).exists():
76
+ return redirect("setup")
77
+ recipes = Recipe.objects.order_by("-created_at")
78
+ return render(request, "index.html", {"recipes": recipes})
79
+
80
+
81
+ def setup(request):
82
+ """
83
+ First-time setup page: create initial superuser if none exists.
84
+ Visible only while there are no superusers in the DB.
85
+ """
86
+ # do not allow access if a superuser already exists
87
+ if User.objects.filter(is_superuser=True).exists():
88
+ return redirect("index")
89
+
90
+ if request.method == "POST":
91
+ form = AdminSetupForm(request.POST)
92
+ if form.is_valid():
93
+ user = form.save()
94
+ # log in the newly created admin
95
+ user.backend = "django.contrib.auth.backends.ModelBackend"
96
+ login(request, user)
97
+ messages.success(request, "Admin account created and signed in.")
98
+ return redirect(reverse("admin:index"))
99
+ else:
100
+ form = AdminSetupForm()
101
+
102
+ return render(request, "setup.html", {"form": form})
103
+
104
+
105
+ def signup(request):
106
+ """
107
+ User signup page: create new regular user accounts.
108
+ """
109
+ if request.method == "POST":
110
+ form = UserSignupForm(request.POST)
111
+ if form.is_valid():
112
+ user = form.save()
113
+ # log in the newly created user
114
+ user.backend = "django.contrib.auth.backends.ModelBackend"
115
+ login(request, user)
116
+ messages.success(request, "Account created and signed in.")
117
+ return redirect("index")
118
+ else:
119
+ form = UserSignupForm()
120
+
121
+ return render(request, "signup.html", {"form": form})
@@ -1,60 +0,0 @@
1
- from django import forms
2
- from django.contrib.auth import get_user_model
3
- from .models import Recipe
4
-
5
- User = get_user_model()
6
-
7
-
8
- class AdminSetupForm(forms.Form):
9
- username = forms.CharField(max_length=150, label="Username")
10
- email = forms.EmailField(required=False, label="Email (optional)")
11
- password1 = forms.CharField(label="Password", widget=forms.PasswordInput)
12
- password2 = forms.CharField(label="Confirm password", widget=forms.PasswordInput)
13
- first_name = forms.CharField(max_length=30, required=False, label="First name")
14
- last_name = forms.CharField(max_length=150, required=False, label="Last name")
15
-
16
- def clean_username(self):
17
- username = self.cleaned_data.get("username")
18
- if User.objects.filter(username=username).exists():
19
- raise forms.ValidationError("A user with that username already exists.")
20
- return username
21
-
22
- def clean(self):
23
- cleaned = super().clean()
24
- p1 = cleaned.get("password1")
25
- p2 = cleaned.get("password2")
26
- if p1 and p2 and p1 != p2:
27
- raise forms.ValidationError("Passwords do not match.")
28
- return cleaned
29
-
30
- def save(self):
31
- data = self.cleaned_data
32
- user = User.objects.create_superuser(
33
- username=data["username"],
34
- email=data.get("email") or "",
35
- password=data["password1"],
36
- )
37
- # set optional names
38
- if data.get("first_name"):
39
- user.first_name = data["first_name"]
40
- if data.get("last_name"):
41
- user.last_name = data["last_name"]
42
- user.is_staff = True
43
- user.save()
44
- return user
45
-
46
-
47
- class RecipeForm(forms.ModelForm):
48
- class Meta:
49
- model = Recipe
50
- fields = [
51
- "title",
52
- "description",
53
- "ingredients",
54
- "instructions",
55
- "image",
56
- "tags",
57
- ]
58
- widgets = {
59
- "tags": forms.TextInput(attrs={"placeholder": "tag1,tag2"}),
60
- }
@@ -1,44 +0,0 @@
1
- {% extends "base_pico.html" %}
2
- {% block title %}{{ recipe.title }} — Sandwitch{% endblock %}
3
-
4
- {% block content %}
5
-
6
- {% load markdown_extras %}
7
- <nav aria-label="breadcrumb" class="container">
8
- <a href="{% url 'index' %}">&larr; Back to all</a>
9
- </nav>
10
-
11
- <div class="grid">
12
- <article class="card">
13
- <figure>
14
- {% if recipe.image %}
15
- <img src="{{ recipe.image.url }}" alt="{{ recipe.title }}">
16
- {% endif %}
17
- </figure>
18
- <header>
19
- {{ recipe.title }}
20
- </header>
21
- <h4>Description</h4>
22
- {{ recipe.description|convert_markdown|safe|default:"No description yet." }}
23
-
24
- <h4>Ingredients</h4>
25
- {{ recipe.ingredients|convert_markdown|safe|default:"No ingredients listed." }}
26
-
27
- <h4>Instructions</h4>
28
- {{ recipe.instructions|convert_markdown|safe|default:"No instructions yet." }}
29
-
30
- <div class="tags-row">
31
- <div style="margin-top:12px;">
32
- {% for tag in recipe.tags.all %}
33
- <span class="tag">{{ tag.name }}</span>
34
- {% endfor %}
35
- </div>
36
- </div>
37
- {% if user.is_authenticated and user.is_staff %}
38
- <footer>
39
- <a href="/admin/sandwitches/recipe/{{ recipe.pk }}/change/">Edit</a>
40
- </footer>
41
- {% endif %}
42
- </article>
43
- </div>
44
- {% endblock %}
@@ -1,58 +0,0 @@
1
- from django.shortcuts import render, get_object_or_404, redirect
2
- from django.urls import reverse
3
- from django.contrib import messages
4
- from django.contrib.auth import login
5
- from django.contrib.auth import get_user_model
6
-
7
- from .models import Recipe
8
- from .forms import RecipeForm, AdminSetupForm
9
-
10
- User = get_user_model()
11
-
12
-
13
- def recipe_edit(request, pk):
14
- recipe = get_object_or_404(Recipe, pk=pk)
15
- if request.method == "POST":
16
- form = RecipeForm(request.POST, request.FILES, instance=recipe)
17
- if form.is_valid():
18
- form.save()
19
- return redirect("recipes:admin_list")
20
- else:
21
- form = RecipeForm(instance=recipe)
22
- return render(request, "recipe_form.html", {"form": form, "recipe": recipe})
23
-
24
-
25
- def recipe_detail(request, slug):
26
- recipe = get_object_or_404(Recipe, slug=slug)
27
- return render(request, "detail.html", {"recipe": recipe})
28
-
29
-
30
- def index(request):
31
- if not User.objects.filter(is_superuser=True).exists():
32
- return redirect("setup")
33
- recipes = Recipe.objects.order_by("-created_at")
34
- return render(request, "index.html", {"recipes": recipes})
35
-
36
-
37
- def setup(request):
38
- """
39
- First-time setup page: create initial superuser if none exists.
40
- Visible only while there are no superusers in the DB.
41
- """
42
- # do not allow access if a superuser already exists
43
- if User.objects.filter(is_superuser=True).exists():
44
- return redirect("index")
45
-
46
- if request.method == "POST":
47
- form = AdminSetupForm(request.POST)
48
- if form.is_valid():
49
- user = form.save()
50
- # log in the newly created admin
51
- user.backend = "django.contrib.auth.backends.ModelBackend"
52
- login(request, user)
53
- messages.success(request, "Admin account created and signed in.")
54
- return redirect(reverse("admin:index"))
55
- else:
56
- form = AdminSetupForm()
57
-
58
- return render(request, "setup.html", {"form": form})
File without changes