sandwitches 1.1.0__py3-none-any.whl → 1.3.0__py3-none-any.whl

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.
sandwitches/admin.py CHANGED
@@ -1,6 +1,26 @@
1
1
  from django.contrib import admin
2
- from .models import Recipe, Tag
2
+ from .models import Recipe, Tag, Rating, Profile
3
+ from django.utils.html import format_html
4
+
5
+
6
+ @admin.register(Recipe)
7
+ class RecipeAdmin(admin.ModelAdmin):
8
+ list_display = ("title", "uploaded_by", "created_at", "show_url")
9
+ readonly_fields = ("created_at", "updated_at")
10
+
11
+ def save_model(self, request, obj, form, change):
12
+ # set uploaded_by automatically when creating in admin
13
+ if not change and not obj.uploaded_by:
14
+ obj.uploaded_by = request.user
15
+ super().save_model(request, obj, form, change)
16
+
17
+ def show_url(self, obj):
18
+ url = obj.get_absolute_url()
19
+ return format_html("<a href='{url}'>{url}</a>", url=url)
20
+
21
+ show_url.short_description = "Recipe Link" # ty:ignore[unresolved-attribute]
3
22
 
4
23
 
5
- admin.site.register(Recipe)
6
24
  admin.site.register(Tag)
25
+ admin.site.register(Rating)
26
+ admin.site.register(Profile)
sandwitches/api.py CHANGED
@@ -1,5 +1,5 @@
1
1
  from ninja import NinjaAPI
2
- from .models import Recipe
2
+ from .models import Recipe, Tag
3
3
 
4
4
  from ninja import ModelSchema
5
5
  from ninja import Schema
@@ -8,8 +8,11 @@ from django.shortcuts import get_object_or_404
8
8
  from datetime import date
9
9
  import random
10
10
 
11
+ from ninja.security import django_auth
11
12
 
12
- api = NinjaAPI()
13
+ from __init__ import __version__
14
+
15
+ api = NinjaAPI(version=__version__)
13
16
 
14
17
 
15
18
  class RecipeSchema(ModelSchema):
@@ -18,6 +21,12 @@ class RecipeSchema(ModelSchema):
18
21
  fields = "__all__"
19
22
 
20
23
 
24
+ class TagSchema(ModelSchema):
25
+ class Meta:
26
+ model = Tag
27
+ fields = "__all__"
28
+
29
+
21
30
  class UserSchema(ModelSchema):
22
31
  class Meta:
23
32
  model = User
@@ -28,6 +37,16 @@ class Error(Schema):
28
37
  message: str
29
38
 
30
39
 
40
+ class RatingResponseSchema(Schema):
41
+ average: float
42
+ count: int
43
+
44
+
45
+ @api.get("ping")
46
+ def ping(request):
47
+ return {"status": "ok", "message": "pong"}
48
+
49
+
31
50
  @api.get("v1/me", response={200: UserSchema, 403: Error})
32
51
  def me(request):
33
52
  if not request.user.is_authenticated:
@@ -35,17 +54,14 @@ def me(request):
35
54
  return request.user
36
55
 
37
56
 
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
57
+ @api.get("v1/users", auth=django_auth, response=list[UserSchema])
58
+ def users(request):
59
+ return User.objects.all()
43
60
 
44
61
 
45
62
  @api.get("v1/recipes", response=list[RecipeSchema])
46
63
  def get_recipes(request):
47
- recipes = Recipe.objects.all()
48
- return recipes
64
+ return Recipe.objects.all() # ty:ignore[unresolved-attribute]
49
65
 
50
66
 
51
67
  @api.get("v1/recipes/{recipe_id}", response=RecipeSchema)
@@ -56,10 +72,30 @@ def get_recipe(request, recipe_id: int):
56
72
 
57
73
  @api.get("v1/recipe-of-the-day", response=RecipeSchema)
58
74
  def get_recipe_of_the_day(request):
59
- recipes = list(Recipe.objects.all())
75
+ recipes = list(Recipe.objects.all()) # ty:ignore[unresolved-attribute]
60
76
  if not recipes:
61
77
  return None
62
78
  today = date.today()
63
79
  random.seed(today.toordinal())
64
80
  recipe = random.choice(recipes)
65
81
  return recipe
82
+
83
+
84
+ @api.get("v1/recipes/{recipe_id}/rating", response=RatingResponseSchema)
85
+ def get_recipe_rating(request, recipe_id: int):
86
+ recipe = get_object_or_404(Recipe, id=recipe_id)
87
+ return {
88
+ "average": recipe.average_rating(),
89
+ "count": recipe.rating_count(),
90
+ }
91
+
92
+
93
+ @api.get("v1/tags", response=list[TagSchema])
94
+ def get_tags(request):
95
+ return Tag.objects.all() # ty:ignore[unresolved-attribute]
96
+
97
+
98
+ @api.get("v1/tags/{tag_id}", response=TagSchema)
99
+ def get_tag(request, tag_id: int):
100
+ tag = get_object_or_404(Tag, id=tag_id)
101
+ return tag
sandwitches/forms.py CHANGED
@@ -80,10 +80,13 @@ class RecipeForm(forms.ModelForm):
80
80
 
81
81
 
82
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,
83
+ """Form for rating recipes (0-10)."""
84
+
85
+ score = forms.FloatField(
86
+ min_value=0.0,
87
+ max_value=10.0,
88
+ widget=forms.NumberInput(
89
+ attrs={"step": "0.1", "min": "0", "max": "10", "class": "slider"}
90
+ ),
88
91
  label="Your rating",
89
92
  )
@@ -0,0 +1,139 @@
1
+ # Dutch translations for Sandwitches project
2
+ msgid ""
3
+ msgstr ""
4
+ "Project-Id-Version: sandwitches 1.0\n"
5
+ "POT-Creation-Date: 2025-01-01 00:00+0000\n"
6
+ "PO-Revision-Date: 2025-01-01 00:00+0000\n"
7
+ "Language: nl\n"
8
+ "MIME-Version: 1.0\n"
9
+ "Content-Type: text/plain; charset=UTF-8\n"
10
+ "Content-Transfer-Encoding: 8bit\n"
11
+
12
+ msgid "Sign up"
13
+ msgstr "Aanmelden"
14
+
15
+ msgid "Sign Up"
16
+ msgstr "Aanmelden"
17
+
18
+ msgid "Cancel"
19
+ msgstr "Annuleren"
20
+
21
+ msgid "Username"
22
+ msgstr "Gebruikersnaam"
23
+
24
+ msgid "Email (optional)"
25
+ msgstr "E-mail (optioneel)"
26
+
27
+ msgid "First name"
28
+ msgstr "Voornaam"
29
+
30
+ msgid "Last name"
31
+ msgstr "Achternaam"
32
+
33
+ msgid "Password"
34
+ msgstr "Wachtwoord"
35
+
36
+ msgid "Confirm password"
37
+ msgstr "Bevestig wachtwoord"
38
+
39
+ msgid "Back to all"
40
+ msgstr "Terug naar alles"
41
+
42
+ msgid "Description"
43
+ msgstr "Beschrijving"
44
+
45
+ msgid "Ingredients"
46
+ msgstr "Ingrediënten"
47
+
48
+ msgid "Instructions"
49
+ msgstr "Instructies"
50
+
51
+ msgid "No description yet."
52
+ msgstr "Nog geen beschrijving."
53
+
54
+ msgid "No ingredients listed."
55
+ msgstr "Geen ingrediënten vermeld."
56
+
57
+ msgid "No instructions yet."
58
+ msgstr "Nog geen instructies."
59
+
60
+ msgid "Rating"
61
+ msgstr "Beoordeling"
62
+
63
+ msgid "Average:"
64
+ msgstr "Gemiddelde:"
65
+
66
+ msgid "No ratings yet."
67
+ msgstr "Nog geen beoordelingen."
68
+
69
+ msgid "Your rating:"
70
+ msgstr "Jouw beoordeling:"
71
+
72
+ msgid "What would you rate this sandwich?"
73
+ msgstr "Wat zou je dit broodje geven?"
74
+
75
+ msgid "Update"
76
+ msgstr "Bijwerken"
77
+
78
+ msgid "Rate"
79
+ msgstr "Beoordeel"
80
+
81
+ msgid "Login"
82
+ msgstr "Inloggen"
83
+
84
+ msgid "to rate this recipe."
85
+ msgstr "om dit recept te beoordelen."
86
+
87
+ msgid "Docs"
88
+ msgstr "Docs"
89
+
90
+ msgid "Admin"
91
+ msgstr "Beheer"
92
+
93
+ msgid "Logout"
94
+ msgstr "Uitloggen"
95
+
96
+ msgid "Initial setup — Create admin"
97
+ msgstr "Eerste installatie — Beheerder aanmaken"
98
+
99
+ msgid "Create initial administrator"
100
+ msgstr "Maak de eerste beheerder"
101
+
102
+ msgid "This page is only available when there are no admin users in the database."
103
+ msgstr "Deze pagina is alleen beschikbaar als er nog geen beheerders in de database zijn."
104
+
105
+ msgid "After creating the account you will be logged in and redirected to the admin."
106
+ msgstr "Na aanmaken wordt u ingelogd en doorgestuurd naar het beheerpaneel."
107
+
108
+ msgid "Create admin"
109
+ msgstr "Maak beheerder"
110
+
111
+ msgid "Account created and signed in."
112
+ msgstr "Account aangemaakt en ingelogd."
113
+
114
+ msgid "Admin account created and signed in."
115
+ msgstr "Beheerderaccount aangemaakt en ingelogd."
116
+
117
+ msgid "Your rating has been saved."
118
+ msgstr "Je beoordeling is opgeslagen."
119
+
120
+ msgid "Could not save rating."
121
+ msgstr "Kon de beoordeling niet opslaan."
122
+
123
+ msgid "Uploaded by"
124
+ msgstr "Geüpload door"
125
+
126
+ msgid "Search by title or tag"
127
+ msgstr "Zoek op titel of tag"
128
+
129
+ msgid "Search"
130
+ msgstr "Zoeken"
131
+
132
+ msgid "No sandwitches yet, please stay tuned."
133
+ msgstr "Nog geen broodjes, kom later terug."
134
+
135
+ msgid "Edit"
136
+ msgstr "Bewerk"
137
+
138
+ msgid "Sandwitches: sandwiches so good, they haunt you!"
139
+ msgstr "Sandwitches: broodjes zo lekker, dat ze je achtervolgen!"
@@ -0,0 +1,25 @@
1
+ # Generated by ChatGPT — add uploaded_by to Recipe
2
+ from django.conf import settings
3
+ from django.db import migrations, models
4
+ import django.db.models.deletion
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+ dependencies = [
9
+ ("sandwitches", "0003_rating"),
10
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
11
+ ]
12
+
13
+ operations = [
14
+ migrations.AddField(
15
+ model_name="recipe",
16
+ name="uploaded_by",
17
+ field=models.ForeignKey(
18
+ blank=True,
19
+ null=True,
20
+ on_delete=django.db.models.deletion.SET_NULL,
21
+ related_name="recipes",
22
+ to=settings.AUTH_USER_MODEL,
23
+ ),
24
+ ),
25
+ ]
@@ -0,0 +1,27 @@
1
+ # Generated by Django 6.0 on 2025-12-28 18:36
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", "0004_add_uploaded_by"),
11
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12
+ ]
13
+
14
+ operations = [
15
+ migrations.AddField(
16
+ model_name="historicalrecipe",
17
+ name="uploaded_by",
18
+ field=models.ForeignKey(
19
+ blank=True,
20
+ db_constraint=False,
21
+ null=True,
22
+ on_delete=django.db.models.deletion.DO_NOTHING,
23
+ related_name="+",
24
+ to=settings.AUTH_USER_MODEL,
25
+ ),
26
+ ),
27
+ ]
@@ -0,0 +1,48 @@
1
+ # Generated by Django 6.0 on 2025-12-29 17:38
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", "0005_historicalrecipe_uploaded_by"),
11
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12
+ ]
13
+
14
+ operations = [
15
+ migrations.CreateModel(
16
+ name="Profile",
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
+ "avatar",
29
+ models.ImageField(blank=True, null=True, upload_to="avatars"),
30
+ ),
31
+ ("bio", models.TextField(blank=True)),
32
+ ("created_at", models.DateTimeField(auto_now_add=True)),
33
+ ("updated_at", models.DateTimeField(auto_now=True)),
34
+ (
35
+ "user",
36
+ models.OneToOneField(
37
+ on_delete=django.db.models.deletion.CASCADE,
38
+ related_name="profile",
39
+ to=settings.AUTH_USER_MODEL,
40
+ ),
41
+ ),
42
+ ],
43
+ options={
44
+ "verbose_name": "Profile",
45
+ "verbose_name_plural": "Profiles",
46
+ },
47
+ ),
48
+ ]
@@ -0,0 +1,23 @@
1
+ # Generated by Django 6.0 on 2025-12-29 17:58
2
+
3
+ import django.core.validators
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+ dependencies = [
9
+ ("sandwitches", "0006_profile"),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AlterField(
14
+ model_name="rating",
15
+ name="score",
16
+ field=models.FloatField(
17
+ validators=[
18
+ django.core.validators.MinValueValidator(0.0),
19
+ django.core.validators.MaxValueValidator(10.0),
20
+ ]
21
+ ),
22
+ ),
23
+ ]
sandwitches/models.py CHANGED
@@ -1,16 +1,44 @@
1
1
  from django.db import models
2
- from django.urls import reverse
3
2
  from django.utils.text import slugify
4
3
  from .storage import HashedFilenameStorage
5
4
  from simple_history.models import HistoricalRecords
6
5
  from django.contrib.auth import get_user_model
7
6
  from django.db.models import Avg
7
+ from .tasks import email_users
8
+ from django.conf import settings
9
+ from django.core.validators import MinValueValidator, MaxValueValidator
10
+ import logging
11
+ from django.urls import reverse
12
+
13
+ from imagekit.models import ImageSpecField
14
+ from imagekit.processors import ResizeToFill
8
15
 
9
16
  hashed_storage = HashedFilenameStorage()
10
17
 
11
18
  User = get_user_model()
12
19
 
13
20
 
21
+ class Profile(models.Model):
22
+ user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
23
+ avatar = models.ImageField(upload_to="avatars", blank=True, null=True)
24
+ avatar_thumbnail = ImageSpecField(
25
+ source="avatar",
26
+ processors=[ResizeToFill(100, 50)],
27
+ format="JPEG",
28
+ options={"quality": 60},
29
+ )
30
+ bio = models.TextField(blank=True)
31
+ created_at = models.DateTimeField(auto_now_add=True)
32
+ updated_at = models.DateTimeField(auto_now=True)
33
+
34
+ class Meta:
35
+ verbose_name = "Profile"
36
+ verbose_name_plural = "Profiles"
37
+
38
+ def __str__(self):
39
+ return f"{self.user.username}'s Profile" # ty:ignore[possibly-missing-attribute]
40
+
41
+
14
42
  class Tag(models.Model):
15
43
  name = models.CharField(max_length=50, unique=True)
16
44
  slug = models.SlugField(max_length=60, unique=True, blank=True)
@@ -25,7 +53,7 @@ class Tag(models.Model):
25
53
  base = slugify(self.name)[:55]
26
54
  slug = base
27
55
  n = 1
28
- while Tag.objects.filter(slug=slug).exclude(pk=self.pk).exists():
56
+ while Tag.objects.filter(slug=slug).exclude(pk=self.pk).exists(): # ty:ignore[unresolved-attribute]
29
57
  slug = f"{base}-{n}"
30
58
  n += 1
31
59
  self.slug = slug
@@ -41,16 +69,44 @@ class Recipe(models.Model):
41
69
  description = models.TextField(blank=True)
42
70
  ingredients = models.TextField(blank=True)
43
71
  instructions = models.TextField(blank=True)
72
+ uploaded_by = models.ForeignKey(
73
+ User,
74
+ related_name="recipes",
75
+ on_delete=models.SET_NULL,
76
+ null=True,
77
+ blank=True,
78
+ )
44
79
  image = models.ImageField(
45
- upload_to="recipes/", # storage will replace with hashed path
80
+ upload_to="recipes/",
46
81
  storage=hashed_storage,
47
82
  blank=True,
48
83
  null=True,
49
84
  )
50
-
51
- # ManyToMany: tags are reusable and shared between recipes
85
+ image_thumbnail = ImageSpecField(
86
+ source="image",
87
+ processors=[ResizeToFill(150, 150)],
88
+ format="JPEG",
89
+ options={"quality": 70},
90
+ )
91
+ image_small = ImageSpecField(
92
+ source="image",
93
+ processors=[ResizeToFill(400, 300)],
94
+ format="JPEG",
95
+ options={"quality": 75},
96
+ )
97
+ image_medium = ImageSpecField(
98
+ source="image",
99
+ processors=[ResizeToFill(700, 500)],
100
+ format="JPEG",
101
+ options={"quality": 85},
102
+ )
103
+ image_large = ImageSpecField(
104
+ source="image",
105
+ processors=[ResizeToFill(1200, 800)],
106
+ format="JPEG",
107
+ options={"quality": 95},
108
+ )
52
109
  tags = models.ManyToManyField(Tag, blank=True, related_name="recipes")
53
-
54
110
  created_at = models.DateTimeField(auto_now_add=True)
55
111
  updated_at = models.DateTimeField(auto_now=True)
56
112
  history = HistoricalRecords()
@@ -61,19 +117,39 @@ class Recipe(models.Model):
61
117
  verbose_name_plural = "Recipes"
62
118
 
63
119
  def save(self, *args, **kwargs):
120
+ is_new = self._state.adding
121
+
64
122
  if not self.slug:
65
123
  base = slugify(self.title)[:240]
66
124
  slug = base
67
125
  n = 1
68
- while Recipe.objects.filter(slug=slug).exclude(pk=self.pk).exists():
126
+ while Recipe.objects.filter(slug=slug).exclude(pk=self.pk).exists(): # ty:ignore[unresolved-attribute]
69
127
  slug = f"{base}-{n}"
70
128
  n += 1
71
129
  self.slug = slug
130
+
72
131
  super().save(*args, **kwargs)
73
132
 
133
+ send_email = getattr(settings, "SEND_EMAIL")
134
+ logging.debug(f"SEND_EMAIL is set to {send_email}")
135
+
136
+ if is_new or settings.DEBUG:
137
+ if send_email:
138
+ email_users.enqueue(recipe_id=self.pk)
139
+ else:
140
+ logging.warning(
141
+ "Email sending is disabled; not sending email notification, make sure SEND_EMAIL is set to True in settings."
142
+ )
143
+ else:
144
+ logging.debug(
145
+ "Existing recipe saved (update); skipping email notification."
146
+ )
147
+
148
+ def get_absolute_url(self):
149
+ return reverse("recipe_detail", kwargs={"slug": self.slug})
150
+
74
151
  def tag_list(self):
75
- # returns list of tag names
76
- return list(self.tags.values_list("name", flat=True))
152
+ return list(self.tags.values_list("name", flat=True)) # ty:ignore[possibly-missing-attribute]
77
153
 
78
154
  def set_tags_from_string(self, tag_string):
79
155
  """
@@ -83,24 +159,19 @@ class Recipe(models.Model):
83
159
  names = [t.strip() for t in (tag_string or "").split(",") if t.strip()]
84
160
  tags = []
85
161
  for name in names:
86
- tag = Tag.objects.filter(name__iexact=name).first()
162
+ tag = Tag.objects.filter(name__iexact=name).first() # ty:ignore[unresolved-attribute]
87
163
  if not tag:
88
- tag = Tag.objects.create(name=name)
164
+ tag = Tag.objects.create(name=name) # ty:ignore[unresolved-attribute]
89
165
  tags.append(tag)
90
- # replace existing tags with these
91
- self.tags.set(tags)
92
- return self.tags.all()
166
+ self.tags.set(tags) # ty:ignore[possibly-missing-attribute]
167
+ return self.tags.all() # ty:ignore[possibly-missing-attribute]
93
168
 
94
- # add helper methods for ratings
95
169
  def average_rating(self):
96
- agg = self.ratings.aggregate(avg=Avg("score"))
170
+ agg = self.ratings.aggregate(avg=Avg("score")) # ty:ignore[unresolved-attribute]
97
171
  return agg["avg"] or 0
98
172
 
99
173
  def rating_count(self):
100
- return self.ratings.count()
101
-
102
- def get_absolute_url(self):
103
- return reverse("recipe_detail", kwargs={"pk": self.pk, "slug": self.slug})
174
+ return self.ratings.count() # ty:ignore[unresolved-attribute]
104
175
 
105
176
  def __str__(self):
106
177
  return self.title
@@ -109,7 +180,9 @@ class Recipe(models.Model):
109
180
  class Rating(models.Model):
110
181
  recipe = models.ForeignKey(Recipe, related_name="ratings", on_delete=models.CASCADE)
111
182
  user = models.ForeignKey(User, related_name="ratings", on_delete=models.CASCADE)
112
- score = models.PositiveSmallIntegerField(choices=[(i, i) for i in range(1, 6)])
183
+ score = models.FloatField(
184
+ validators=[MinValueValidator(0.0), MaxValueValidator(10.0)]
185
+ )
113
186
  created_at = models.DateTimeField(auto_now_add=True)
114
187
  updated_at = models.DateTimeField(auto_now=True)
115
188