sandwitches 2.4.0__py3-none-any.whl → 2.5.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 +10 -2
- sandwitches/forms.py +78 -13
- sandwitches/migrations/0015_order_completed_alter_order_status_and_more.py +56 -0
- sandwitches/migrations/0016_user_theme.py +21 -0
- sandwitches/migrations/0017_setting_gotify_token_setting_gotify_url.py +31 -0
- sandwitches/models.py +47 -2
- sandwitches/tasks.py +31 -1
- sandwitches/templates/admin/admin_base.html +0 -3
- sandwitches/templates/admin/partials/order_rows.html +1 -1
- sandwitches/templates/admin/recipe_form.html +113 -18
- sandwitches/templates/base.html +1 -1
- sandwitches/templates/base_beer.html +0 -1
- sandwitches/templates/community.html +111 -14
- sandwitches/templates/components/navbar.html +0 -6
- sandwitches/templates/components/side_menu.html +4 -0
- sandwitches/templates/components/user_menu.html +1 -0
- sandwitches/templates/order_detail.html +68 -0
- sandwitches/templates/profile.html +91 -0
- sandwitches/templates/settings.html +53 -0
- sandwitches/templates/signup.html +0 -12
- sandwitches/urls.py +2 -0
- sandwitches/views.py +74 -1
- {sandwitches-2.4.0.dist-info → sandwitches-2.5.0.dist-info}/METADATA +1 -1
- {sandwitches-2.4.0.dist-info → sandwitches-2.5.0.dist-info}/RECORD +25 -21
- sandwitches/templates/components/language_dialog.html +0 -26
- {sandwitches-2.4.0.dist-info → sandwitches-2.5.0.dist-info}/WHEEL +0 -0
sandwitches/admin.py
CHANGED
|
@@ -84,7 +84,15 @@ class RatingAdmin(ImportExportModelAdmin):
|
|
|
84
84
|
@admin.register(Order)
|
|
85
85
|
class OrderAdmin(ImportExportModelAdmin):
|
|
86
86
|
resource_classes = [OrderResource]
|
|
87
|
-
list_display = (
|
|
88
|
-
|
|
87
|
+
list_display = (
|
|
88
|
+
"id",
|
|
89
|
+
"user",
|
|
90
|
+
"recipe",
|
|
91
|
+
"status",
|
|
92
|
+
"completed",
|
|
93
|
+
"total_price",
|
|
94
|
+
"created_at",
|
|
95
|
+
)
|
|
96
|
+
list_filter = ("status", "completed", "created_at")
|
|
89
97
|
search_fields = ("user__username", "recipe__title")
|
|
90
98
|
readonly_fields = ("total_price", "created_at", "updated_at")
|
sandwitches/forms.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
from django import forms
|
|
2
|
-
from django.conf import settings
|
|
3
2
|
from django.contrib.auth import get_user_model
|
|
4
3
|
from django.contrib.auth.forms import UserCreationForm
|
|
5
4
|
from django.utils.translation import gettext_lazy as _
|
|
@@ -47,11 +46,6 @@ class AdminSetupForm(forms.ModelForm, BaseUserFormMixin):
|
|
|
47
46
|
|
|
48
47
|
|
|
49
48
|
class UserSignupForm(UserCreationForm, BaseUserFormMixin):
|
|
50
|
-
language = forms.ChoiceField(
|
|
51
|
-
choices=settings.LANGUAGES,
|
|
52
|
-
label=_("Preferred language"),
|
|
53
|
-
initial=settings.LANGUAGE_CODE,
|
|
54
|
-
)
|
|
55
49
|
avatar = forms.ImageField(label=_("Profile Image"), required=False)
|
|
56
50
|
bio = forms.CharField(
|
|
57
51
|
widget=forms.Textarea(attrs={"rows": 3}), label=_("Bio"), required=False
|
|
@@ -64,7 +58,6 @@ class UserSignupForm(UserCreationForm, BaseUserFormMixin):
|
|
|
64
58
|
"first_name",
|
|
65
59
|
"last_name",
|
|
66
60
|
"email",
|
|
67
|
-
"language",
|
|
68
61
|
"avatar",
|
|
69
62
|
"bio",
|
|
70
63
|
)
|
|
@@ -77,7 +70,6 @@ class UserSignupForm(UserCreationForm, BaseUserFormMixin):
|
|
|
77
70
|
user.is_superuser = False
|
|
78
71
|
user.is_staff = False
|
|
79
72
|
# Explicitly save the extra fields if they aren't automatically handled by ModelForm save (they should be if in Meta.fields)
|
|
80
|
-
user.language = self.cleaned_data["language"]
|
|
81
73
|
user.avatar = self.cleaned_data["avatar"]
|
|
82
74
|
user.bio = self.cleaned_data["bio"]
|
|
83
75
|
if commit:
|
|
@@ -86,6 +78,8 @@ class UserSignupForm(UserCreationForm, BaseUserFormMixin):
|
|
|
86
78
|
|
|
87
79
|
|
|
88
80
|
class UserProfileForm(forms.ModelForm):
|
|
81
|
+
image_data = forms.CharField(widget=forms.HiddenInput(), required=False)
|
|
82
|
+
|
|
89
83
|
class Meta:
|
|
90
84
|
model = User
|
|
91
85
|
fields = (
|
|
@@ -96,8 +90,35 @@ class UserProfileForm(forms.ModelForm):
|
|
|
96
90
|
"bio",
|
|
97
91
|
)
|
|
98
92
|
|
|
93
|
+
def save(self, commit=True):
|
|
94
|
+
user = super().save(commit=False)
|
|
95
|
+
image_data = self.cleaned_data.get("image_data")
|
|
96
|
+
if image_data and image_data.startswith("data:image"):
|
|
97
|
+
import base64
|
|
98
|
+
from django.core.files.base import ContentFile
|
|
99
|
+
|
|
100
|
+
format, imgstr = image_data.split(";base64,")
|
|
101
|
+
ext = format.split("/")[-1]
|
|
102
|
+
data = ContentFile(base64.b64decode(imgstr), name=f"avatar.{ext}")
|
|
103
|
+
user.avatar = data
|
|
104
|
+
if commit:
|
|
105
|
+
user.save()
|
|
106
|
+
return user
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class UserSettingsForm(forms.ModelForm):
|
|
110
|
+
class Meta:
|
|
111
|
+
model = User
|
|
112
|
+
fields = ("language", "theme")
|
|
113
|
+
labels = {
|
|
114
|
+
"language": _("Preferred Language"),
|
|
115
|
+
"theme": _("Preferred Theme"),
|
|
116
|
+
}
|
|
117
|
+
|
|
99
118
|
|
|
100
119
|
class UserEditForm(forms.ModelForm):
|
|
120
|
+
image_data = forms.CharField(widget=forms.HiddenInput(), required=False)
|
|
121
|
+
|
|
101
122
|
class Meta:
|
|
102
123
|
model = User
|
|
103
124
|
fields = (
|
|
@@ -112,6 +133,21 @@ class UserEditForm(forms.ModelForm):
|
|
|
112
133
|
"bio",
|
|
113
134
|
)
|
|
114
135
|
|
|
136
|
+
def save(self, commit=True):
|
|
137
|
+
user = super().save(commit=False)
|
|
138
|
+
image_data = self.cleaned_data.get("image_data")
|
|
139
|
+
if image_data and image_data.startswith("data:image"):
|
|
140
|
+
import base64
|
|
141
|
+
from django.core.files.base import ContentFile
|
|
142
|
+
|
|
143
|
+
format, imgstr = image_data.split(";base64,")
|
|
144
|
+
ext = format.split("/")[-1]
|
|
145
|
+
data = ContentFile(base64.b64decode(imgstr), name=f"avatar.{ext}")
|
|
146
|
+
user.avatar = data
|
|
147
|
+
if commit:
|
|
148
|
+
user.save()
|
|
149
|
+
return user
|
|
150
|
+
|
|
115
151
|
|
|
116
152
|
class TagForm(forms.ModelForm):
|
|
117
153
|
class Meta:
|
|
@@ -126,6 +162,7 @@ class RecipeForm(forms.ModelForm):
|
|
|
126
162
|
widget=forms.TextInput(attrs={"placeholder": _("e.g. spicy, vegan, quick")}),
|
|
127
163
|
)
|
|
128
164
|
rotation = forms.IntegerField(widget=forms.HiddenInput(), initial=0, required=False)
|
|
165
|
+
image_data = forms.CharField(widget=forms.HiddenInput(), required=False)
|
|
129
166
|
|
|
130
167
|
class Meta:
|
|
131
168
|
model = Recipe
|
|
@@ -153,11 +190,22 @@ class RecipeForm(forms.ModelForm):
|
|
|
153
190
|
)
|
|
154
191
|
|
|
155
192
|
def save(self, commit=True):
|
|
156
|
-
recipe = super().save(commit=
|
|
193
|
+
recipe = super().save(commit=False)
|
|
194
|
+
|
|
195
|
+
# Handle base64 image data from cropper
|
|
196
|
+
image_data = self.cleaned_data.get("image_data")
|
|
197
|
+
if image_data and image_data.startswith("data:image"):
|
|
198
|
+
import base64
|
|
199
|
+
from django.core.files.base import ContentFile
|
|
200
|
+
|
|
201
|
+
format, imgstr = image_data.split(";base64,")
|
|
202
|
+
ext = format.split("/")[-1]
|
|
203
|
+
data = ContentFile(base64.b64decode(imgstr), name=f"recipe_image.{ext}")
|
|
204
|
+
recipe.image = data
|
|
157
205
|
|
|
158
|
-
# Handle rotation if an image exists and rotation is requested
|
|
206
|
+
# Handle rotation if an image exists and rotation is requested (fallback for simple rotation)
|
|
159
207
|
rotation = self.cleaned_data.get("rotation", 0)
|
|
160
|
-
if rotation != 0 and recipe.image:
|
|
208
|
+
if rotation != 0 and recipe.image and not image_data:
|
|
161
209
|
try:
|
|
162
210
|
from PIL import Image as PILImage
|
|
163
211
|
|
|
@@ -169,9 +217,9 @@ class RecipeForm(forms.ModelForm):
|
|
|
169
217
|
print(f"Error rotating image: {e}")
|
|
170
218
|
|
|
171
219
|
if commit:
|
|
220
|
+
recipe.save()
|
|
172
221
|
recipe.set_tags_from_string(self.cleaned_data.get("tags_string", ""))
|
|
173
222
|
else:
|
|
174
|
-
# We'll need to handle this in the view if commit=False
|
|
175
223
|
self.save_m2m = lambda: recipe.set_tags_from_string(
|
|
176
224
|
self.cleaned_data.get("tags_string", "")
|
|
177
225
|
)
|
|
@@ -184,6 +232,7 @@ class UserRecipeSubmissionForm(forms.ModelForm):
|
|
|
184
232
|
label=_("Tags (comma separated)"),
|
|
185
233
|
widget=forms.TextInput(attrs={"placeholder": _("e.g. spicy, vegan, quick")}),
|
|
186
234
|
)
|
|
235
|
+
image_data = forms.CharField(widget=forms.HiddenInput(), required=False)
|
|
187
236
|
|
|
188
237
|
class Meta:
|
|
189
238
|
model = Recipe
|
|
@@ -201,8 +250,21 @@ class UserRecipeSubmissionForm(forms.ModelForm):
|
|
|
201
250
|
}
|
|
202
251
|
|
|
203
252
|
def save(self, commit=True):
|
|
204
|
-
recipe = super().save(commit=
|
|
253
|
+
recipe = super().save(commit=False)
|
|
254
|
+
|
|
255
|
+
# Handle base64 image data from cropper
|
|
256
|
+
image_data = self.cleaned_data.get("image_data")
|
|
257
|
+
if image_data and image_data.startswith("data:image"):
|
|
258
|
+
import base64
|
|
259
|
+
from django.core.files.base import ContentFile
|
|
260
|
+
|
|
261
|
+
format, imgstr = image_data.split(";base64,")
|
|
262
|
+
ext = format.split("/")[-1]
|
|
263
|
+
data = ContentFile(base64.b64decode(imgstr), name=f"recipe_image.{ext}")
|
|
264
|
+
recipe.image = data
|
|
265
|
+
|
|
205
266
|
if commit:
|
|
267
|
+
recipe.save()
|
|
206
268
|
recipe.set_tags_from_string(self.cleaned_data.get("tags_string", ""))
|
|
207
269
|
else:
|
|
208
270
|
self.save_m2m = lambda: recipe.set_tags_from_string(
|
|
@@ -239,7 +301,10 @@ class SettingForm(forms.ModelForm):
|
|
|
239
301
|
"ai_connection_point",
|
|
240
302
|
"ai_model",
|
|
241
303
|
"ai_api_key",
|
|
304
|
+
"gotify_url",
|
|
305
|
+
"gotify_token",
|
|
242
306
|
]
|
|
243
307
|
widgets = {
|
|
244
308
|
"ai_api_key": forms.PasswordInput(render_value=True),
|
|
309
|
+
"gotify_token": forms.PasswordInput(render_value=True),
|
|
245
310
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Generated by Django 6.0.1 on 2026-01-27 08:33
|
|
2
|
+
|
|
3
|
+
import django.core.validators
|
|
4
|
+
from django.db import migrations, models
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Migration(migrations.Migration):
|
|
8
|
+
dependencies = [
|
|
9
|
+
("sandwitches", "0014_ensure_groups_exist"),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AddField(
|
|
14
|
+
model_name="order",
|
|
15
|
+
name="completed",
|
|
16
|
+
field=models.BooleanField(default=False),
|
|
17
|
+
),
|
|
18
|
+
migrations.AlterField(
|
|
19
|
+
model_name="order",
|
|
20
|
+
name="status",
|
|
21
|
+
field=models.CharField(
|
|
22
|
+
choices=[
|
|
23
|
+
("PENDING", "Pending"),
|
|
24
|
+
("PREPARING", "Preparing"),
|
|
25
|
+
("MADE", "Made"),
|
|
26
|
+
("SHIPPED", "Shipped"),
|
|
27
|
+
("COMPLETED", "Completed"),
|
|
28
|
+
("CANCELLED", "Cancelled"),
|
|
29
|
+
],
|
|
30
|
+
default="PENDING",
|
|
31
|
+
max_length=20,
|
|
32
|
+
),
|
|
33
|
+
),
|
|
34
|
+
migrations.AlterField(
|
|
35
|
+
model_name="setting",
|
|
36
|
+
name="email",
|
|
37
|
+
field=models.EmailField(
|
|
38
|
+
blank=True,
|
|
39
|
+
max_length=254,
|
|
40
|
+
null=True,
|
|
41
|
+
validators=[django.core.validators.EmailValidator()],
|
|
42
|
+
),
|
|
43
|
+
),
|
|
44
|
+
migrations.AlterField(
|
|
45
|
+
model_name="user",
|
|
46
|
+
name="email",
|
|
47
|
+
field=models.EmailField(
|
|
48
|
+
max_length=254, validators=[django.core.validators.EmailValidator()]
|
|
49
|
+
),
|
|
50
|
+
),
|
|
51
|
+
migrations.AlterField(
|
|
52
|
+
model_name="user",
|
|
53
|
+
name="username",
|
|
54
|
+
field=models.CharField(max_length=150, unique=True),
|
|
55
|
+
),
|
|
56
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Generated by Django 6.0.1 on 2026-01-28 09:08
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
dependencies = [
|
|
8
|
+
("sandwitches", "0015_order_completed_alter_order_status_and_more"),
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
operations = [
|
|
12
|
+
migrations.AddField(
|
|
13
|
+
model_name="user",
|
|
14
|
+
name="theme",
|
|
15
|
+
field=models.CharField(
|
|
16
|
+
choices=[("light", "Light"), ("dark", "Dark")],
|
|
17
|
+
default="light",
|
|
18
|
+
max_length=10,
|
|
19
|
+
),
|
|
20
|
+
),
|
|
21
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Generated by Django 6.0.1 on 2026-01-28 12:45
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
dependencies = [
|
|
8
|
+
("sandwitches", "0016_user_theme"),
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
operations = [
|
|
12
|
+
migrations.AddField(
|
|
13
|
+
model_name="setting",
|
|
14
|
+
name="gotify_token",
|
|
15
|
+
field=models.CharField(
|
|
16
|
+
blank=True,
|
|
17
|
+
help_text="The application token for Gotify",
|
|
18
|
+
max_length=255,
|
|
19
|
+
null=True,
|
|
20
|
+
),
|
|
21
|
+
),
|
|
22
|
+
migrations.AddField(
|
|
23
|
+
model_name="setting",
|
|
24
|
+
name="gotify_url",
|
|
25
|
+
field=models.URLField(
|
|
26
|
+
blank=True,
|
|
27
|
+
help_text="The URL of your Gotify server (e.g., https://gotify.example.com)",
|
|
28
|
+
null=True,
|
|
29
|
+
),
|
|
30
|
+
),
|
|
31
|
+
]
|
sandwitches/models.py
CHANGED
|
@@ -4,13 +4,14 @@ from .storage import HashedFilenameStorage
|
|
|
4
4
|
from simple_history.models import HistoricalRecords
|
|
5
5
|
from django.contrib.auth.models import AbstractUser
|
|
6
6
|
from django.db.models import Avg
|
|
7
|
-
from .tasks import email_users, notify_order_submitted
|
|
7
|
+
from .tasks import email_users, notify_order_submitted, send_gotify_notification
|
|
8
8
|
from django.conf import settings
|
|
9
9
|
from django.core.validators import MinValueValidator, MaxValueValidator
|
|
10
10
|
import logging
|
|
11
11
|
from django.urls import reverse
|
|
12
12
|
from solo.models import SingletonModel
|
|
13
13
|
from django.core.exceptions import ValidationError
|
|
14
|
+
from django.core.validators import EmailValidator
|
|
14
15
|
|
|
15
16
|
from imagekit.models import ImageSpecField
|
|
16
17
|
from imagekit.processors import ResizeToFill
|
|
@@ -21,11 +22,23 @@ hashed_storage = HashedFilenameStorage()
|
|
|
21
22
|
class Setting(SingletonModel):
|
|
22
23
|
site_name = models.CharField(max_length=255, default="Sandwitches")
|
|
23
24
|
site_description = models.TextField(blank=True, null=True)
|
|
24
|
-
email = models.EmailField(blank=True, null=True)
|
|
25
|
+
email = models.EmailField(blank=True, null=True, validators=[EmailValidator()])
|
|
25
26
|
ai_connection_point = models.URLField(blank=True, null=True)
|
|
26
27
|
ai_model = models.CharField(max_length=255, blank=True, null=True)
|
|
27
28
|
ai_api_key = models.CharField(max_length=255, blank=True, null=True)
|
|
28
29
|
|
|
30
|
+
gotify_url = models.URLField(
|
|
31
|
+
blank=True,
|
|
32
|
+
null=True,
|
|
33
|
+
help_text="The URL of your Gotify server (e.g., https://gotify.example.com)",
|
|
34
|
+
)
|
|
35
|
+
gotify_token = models.CharField(
|
|
36
|
+
max_length=255,
|
|
37
|
+
blank=True,
|
|
38
|
+
null=True,
|
|
39
|
+
help_text="The application token for Gotify",
|
|
40
|
+
)
|
|
41
|
+
|
|
29
42
|
def __str__(self):
|
|
30
43
|
return "Site Settings"
|
|
31
44
|
|
|
@@ -34,6 +47,8 @@ class Setting(SingletonModel):
|
|
|
34
47
|
|
|
35
48
|
|
|
36
49
|
class User(AbstractUser):
|
|
50
|
+
username = models.CharField(max_length=150, unique=True)
|
|
51
|
+
email = models.EmailField(validators=[EmailValidator()])
|
|
37
52
|
avatar = models.ImageField(upload_to="avatars", blank=True, null=True)
|
|
38
53
|
avatar_thumbnail = ImageSpecField(
|
|
39
54
|
source="avatar",
|
|
@@ -47,6 +62,11 @@ class User(AbstractUser):
|
|
|
47
62
|
choices=settings.LANGUAGES,
|
|
48
63
|
default=settings.LANGUAGE_CODE,
|
|
49
64
|
)
|
|
65
|
+
theme = models.CharField(
|
|
66
|
+
max_length=10,
|
|
67
|
+
choices=[("light", "Light"), ("dark", "Dark")],
|
|
68
|
+
default="light",
|
|
69
|
+
)
|
|
50
70
|
favorites = models.ManyToManyField(
|
|
51
71
|
"Recipe", related_name="favorited_by", blank=True
|
|
52
72
|
)
|
|
@@ -58,6 +78,16 @@ class User(AbstractUser):
|
|
|
58
78
|
def __str__(self):
|
|
59
79
|
return self.username
|
|
60
80
|
|
|
81
|
+
def save(self, *args, **kwargs):
|
|
82
|
+
is_new = self.pk is None
|
|
83
|
+
super().save(*args, **kwargs)
|
|
84
|
+
if is_new:
|
|
85
|
+
send_gotify_notification.enqueue(
|
|
86
|
+
title="New User Created",
|
|
87
|
+
message=f"User {self.username} has joined Sandwitches!",
|
|
88
|
+
priority=4,
|
|
89
|
+
)
|
|
90
|
+
|
|
61
91
|
|
|
62
92
|
class Tag(models.Model):
|
|
63
93
|
name = models.CharField(max_length=50, unique=True)
|
|
@@ -170,6 +200,12 @@ class Recipe(models.Model):
|
|
|
170
200
|
logging.warning(
|
|
171
201
|
"Email sending is disabled; not sending email notification, make sure SEND_EMAIL is set to True in settings."
|
|
172
202
|
)
|
|
203
|
+
|
|
204
|
+
send_gotify_notification.enqueue(
|
|
205
|
+
title="New Recipe Uploaded",
|
|
206
|
+
message=f"A new recipe '{self.title}' has been uploaded by {self.uploaded_by or 'Unknown'}.",
|
|
207
|
+
priority=5,
|
|
208
|
+
)
|
|
173
209
|
else:
|
|
174
210
|
logging.debug(
|
|
175
211
|
"Existing recipe saved (update); skipping email notification."
|
|
@@ -230,6 +266,9 @@ class Rating(models.Model):
|
|
|
230
266
|
class Order(models.Model):
|
|
231
267
|
STATUS_CHOICES = (
|
|
232
268
|
("PENDING", "Pending"),
|
|
269
|
+
("PREPARING", "Preparing"),
|
|
270
|
+
("MADE", "Made"),
|
|
271
|
+
("SHIPPED", "Shipped"),
|
|
233
272
|
("COMPLETED", "Completed"),
|
|
234
273
|
("CANCELLED", "Cancelled"),
|
|
235
274
|
)
|
|
@@ -239,6 +278,7 @@ class Order(models.Model):
|
|
|
239
278
|
)
|
|
240
279
|
recipe = models.ForeignKey(Recipe, related_name="orders", on_delete=models.CASCADE)
|
|
241
280
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="PENDING")
|
|
281
|
+
completed = models.BooleanField(default=False)
|
|
242
282
|
total_price = models.DecimalField(max_digits=6, decimal_places=2)
|
|
243
283
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
244
284
|
updated_at = models.DateTimeField(auto_now=True)
|
|
@@ -275,6 +315,11 @@ class Order(models.Model):
|
|
|
275
315
|
|
|
276
316
|
if is_new:
|
|
277
317
|
notify_order_submitted.enqueue(order_id=self.pk)
|
|
318
|
+
send_gotify_notification.enqueue(
|
|
319
|
+
title="New Order Received",
|
|
320
|
+
message=f"Order #{self.pk} for '{self.recipe.title}' by {self.user.username}. Total: {self.total_price}€",
|
|
321
|
+
priority=6,
|
|
322
|
+
)
|
|
278
323
|
|
|
279
324
|
def __str__(self):
|
|
280
325
|
return f"Order #{self.pk} - {self.user} - {self.recipe}"
|
sandwitches/tasks.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
# from gunicorn.http.wsgi import log
|
|
2
1
|
import logging
|
|
2
|
+
import requests
|
|
3
3
|
|
|
4
4
|
# from django.core.mail import send_mail
|
|
5
5
|
from django_tasks import task
|
|
@@ -109,6 +109,36 @@ def notify_order_submitted(order_id):
|
|
|
109
109
|
logging.info(f"Order confirmation email sent to {user.email} for order {order.id}")
|
|
110
110
|
|
|
111
111
|
|
|
112
|
+
@task(priority=1, queue_name="emails")
|
|
113
|
+
def send_gotify_notification(title, message, priority=5):
|
|
114
|
+
from .models import Setting
|
|
115
|
+
|
|
116
|
+
config = Setting.get_solo()
|
|
117
|
+
url = config.gotify_url
|
|
118
|
+
token = config.gotify_token
|
|
119
|
+
|
|
120
|
+
if not url or not token:
|
|
121
|
+
logging.debug("Gotify URL or Token not configured. Skipping notification.")
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
response = requests.post(
|
|
126
|
+
f"{url.rstrip('/')}/message?token={token}",
|
|
127
|
+
json={
|
|
128
|
+
"title": title,
|
|
129
|
+
"message": message,
|
|
130
|
+
"priority": priority,
|
|
131
|
+
},
|
|
132
|
+
timeout=10,
|
|
133
|
+
)
|
|
134
|
+
response.raise_for_status()
|
|
135
|
+
logging.info(f"Gotify notification sent: {title}")
|
|
136
|
+
return True
|
|
137
|
+
except Exception as e:
|
|
138
|
+
logging.error(f"Failed to send Gotify notification: {e}")
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
112
142
|
def send_emails(recipe_id, emails):
|
|
113
143
|
from .models import Recipe
|
|
114
144
|
|
|
@@ -30,9 +30,6 @@
|
|
|
30
30
|
<h6 class="max">{% block admin_title %}{% trans "Sandwitches Admin" %}{% endblock %}</h6>
|
|
31
31
|
</a>
|
|
32
32
|
<div class="max"></div>
|
|
33
|
-
<button class="circle transparent" onclick="toggleMode()">
|
|
34
|
-
<i>dark_mode</i>
|
|
35
|
-
</button>
|
|
36
33
|
|
|
37
34
|
{% if user.avatar %}
|
|
38
35
|
<img src="{{ user.avatar.url }}" class="circle" data-ui="#user-menu">
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
<td>{{ order.recipe.title }}</td>
|
|
16
16
|
<td>{{ order.total_price }} €</td>
|
|
17
17
|
<td>
|
|
18
|
-
<span class="chip {% if order.status == 'PENDING' %}surface-variant{% elif order.status == 'COMPLETED' %}primary{%
|
|
18
|
+
<span class="chip {% if order.status == 'PENDING' %}surface-variant{% elif order.status == 'COMPLETED' %}primary{% elif order.status == 'CANCELLED' %}error{% else %}secondary{% endif %}">
|
|
19
19
|
{{ order.get_status_display }}
|
|
20
20
|
</span>
|
|
21
21
|
</td>
|
|
@@ -31,6 +31,14 @@
|
|
|
31
31
|
.form-section {
|
|
32
32
|
margin-bottom: 2rem;
|
|
33
33
|
}
|
|
34
|
+
/* Cropper styles */
|
|
35
|
+
.cropper-container {
|
|
36
|
+
max-height: 70vh;
|
|
37
|
+
}
|
|
38
|
+
#cropper-image {
|
|
39
|
+
display: block;
|
|
40
|
+
max-width: 100%;
|
|
41
|
+
}
|
|
34
42
|
</style>
|
|
35
43
|
{% endblock %}
|
|
36
44
|
|
|
@@ -38,6 +46,7 @@
|
|
|
38
46
|
<form method="post" enctype="multipart/form-data" id="recipe-form">
|
|
39
47
|
{% csrf_token %}
|
|
40
48
|
{{ form.rotation }}
|
|
49
|
+
{{ form.image_data }}
|
|
41
50
|
<div class="grid">
|
|
42
51
|
<!-- Top Section: Title, Tags, and Image -->
|
|
43
52
|
<div class="s12 m8">
|
|
@@ -107,25 +116,21 @@
|
|
|
107
116
|
|
|
108
117
|
<div class="relative mb-1" style="overflow: hidden; min-height: 200px; display: flex; align-items: center; justify-content: center;">
|
|
109
118
|
{% if recipe.image %}
|
|
110
|
-
<img src="{{ recipe.image_medium.url }}?v={% now "U" %}" class="responsive round" id="image-preview" style="max-height: 300px; width: 100%; object-fit: contain;
|
|
119
|
+
<img src="{{ recipe.image_medium.url }}?v={% now "U" %}" class="responsive round" id="image-preview" style="max-height: 300px; width: 100%; object-fit: contain;">
|
|
111
120
|
{% else %}
|
|
121
|
+
<img src="" class="responsive round" id="image-preview" style="max-height: 300px; width: 100%; object-fit: contain; display: none;">
|
|
112
122
|
<div class="medium-height middle-align center-align gray1 round" id="image-placeholder" style="width: 100%;">
|
|
113
123
|
<i class="extra">image</i>
|
|
114
124
|
</div>
|
|
115
125
|
{% endif %}
|
|
116
126
|
</div>
|
|
117
127
|
|
|
118
|
-
{% if recipe.image %}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
<
|
|
122
|
-
</button>
|
|
123
|
-
<div class="divider vertical"></div>
|
|
124
|
-
<button type="button" class="button transparent max" onclick="rotatePreview(90)" title="{% trans 'Rotate 90° CW' %}">
|
|
125
|
-
<i>rotate_right</i>
|
|
128
|
+
<div id="image-tools" class="row no-space border round mb-1" style="{% if not recipe.image %}display: none;{% endif %}">
|
|
129
|
+
<button type="button" class="button transparent max" onclick="openCropper()" title="{% trans 'Edit Image' %}">
|
|
130
|
+
<i>crop_rotate</i>
|
|
131
|
+
<span>{% trans "Edit" %}</span>
|
|
126
132
|
</button>
|
|
127
133
|
</div>
|
|
128
|
-
{% endif %}
|
|
129
134
|
|
|
130
135
|
<div class="field file border round">
|
|
131
136
|
<input type="text" readonly>
|
|
@@ -174,21 +179,111 @@
|
|
|
174
179
|
</button>
|
|
175
180
|
</nav>
|
|
176
181
|
</form>
|
|
182
|
+
|
|
183
|
+
<dialog id="cropper-dialog" class="large">
|
|
184
|
+
<div class="padding">
|
|
185
|
+
<h5 class="bold mb-1">{% trans "Edit Image" %}</h5>
|
|
186
|
+
<div class="cropper-container mb-1">
|
|
187
|
+
<img id="cropper-image" src="">
|
|
188
|
+
</div>
|
|
189
|
+
<div class="row scroll no-space border round mb-1">
|
|
190
|
+
<button type="button" class="button transparent max" onclick="cropper.rotate(-90)" title="{% trans 'Rotate Left' %}">
|
|
191
|
+
<i>rotate_left</i>
|
|
192
|
+
</button>
|
|
193
|
+
<button type="button" class="button transparent max" onclick="cropper.rotate(90)" title="{% trans 'Rotate Right' %}">
|
|
194
|
+
<i>rotate_right</i>
|
|
195
|
+
</button>
|
|
196
|
+
<div class="divider vertical"></div>
|
|
197
|
+
<button type="button" class="button transparent max" onclick="cropper.scaleX(-cropper.getData().scaleX || -1)" title="{% trans 'Flip Horizontal' %}">
|
|
198
|
+
<i>flip</i>
|
|
199
|
+
</button>
|
|
200
|
+
<button type="button" class="button transparent max" onclick="cropper.scaleY(-cropper.getData().scaleY || -1)" title="{% trans 'Flip Vertical' %}">
|
|
201
|
+
<i>flip</i>
|
|
202
|
+
</button>
|
|
203
|
+
<div class="divider vertical"></div>
|
|
204
|
+
<button type="button" class="button transparent max" onclick="cropper.setAspectRatio(1)" title="{% trans '1:1' %}">1:1</button>
|
|
205
|
+
<button type="button" class="button transparent max" onclick="cropper.setAspectRatio(4/3)" title="{% trans '4:3' %}">4:3</button>
|
|
206
|
+
<button type="button" class="button transparent max" onclick="cropper.setAspectRatio(16/9)" title="{% trans '16:9' %}">16:9</button>
|
|
207
|
+
<button type="button" class="button transparent max" onclick="cropper.setAspectRatio(NaN)" title="{% trans 'Free' %}">
|
|
208
|
+
<i>crop_free</i>
|
|
209
|
+
</button>
|
|
210
|
+
</div>
|
|
211
|
+
<nav class="right-align">
|
|
212
|
+
<button type="button" class="button transparent" onclick="ui('#cropper-dialog')">{% trans "Cancel" %}</button>
|
|
213
|
+
<button type="button" class="button primary" onclick="applyCrop()">{% trans "Apply" %}</button>
|
|
214
|
+
</nav>
|
|
215
|
+
</div>
|
|
216
|
+
</dialog>
|
|
177
217
|
{% endblock %}
|
|
178
218
|
|
|
179
219
|
{% block admin_scripts %}
|
|
180
220
|
<script>
|
|
181
|
-
let
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
221
|
+
let cropper;
|
|
222
|
+
const imagePreview = document.getElementById('image-preview');
|
|
223
|
+
const imagePlaceholder = document.getElementById('image-placeholder');
|
|
224
|
+
const imageTools = document.getElementById('image-tools');
|
|
225
|
+
const imageInput = document.getElementById('id_image');
|
|
226
|
+
const imageDataInput = document.getElementById('id_image_data');
|
|
227
|
+
const cropperImage = document.getElementById('cropper-image');
|
|
228
|
+
|
|
229
|
+
function openCropper() {
|
|
230
|
+
if (!imagePreview.src || imagePreview.src === window.location.href) return;
|
|
231
|
+
cropperImage.src = imagePreview.src;
|
|
232
|
+
ui('#cropper-dialog');
|
|
233
|
+
|
|
234
|
+
if (cropper) {
|
|
235
|
+
cropper.destroy();
|
|
189
236
|
}
|
|
237
|
+
|
|
238
|
+
setTimeout(() => {
|
|
239
|
+
cropper = new Cropper(cropperImage, {
|
|
240
|
+
viewMode: 1,
|
|
241
|
+
autoCropArea: 1,
|
|
242
|
+
responsive: true,
|
|
243
|
+
restore: false,
|
|
244
|
+
checkCrossOrigin: true,
|
|
245
|
+
guides: true,
|
|
246
|
+
center: true,
|
|
247
|
+
highlight: false,
|
|
248
|
+
cropBoxMovable: true,
|
|
249
|
+
cropBoxResizable: true,
|
|
250
|
+
toggleDragModeOnDblclick: false,
|
|
251
|
+
});
|
|
252
|
+
}, 100);
|
|
190
253
|
}
|
|
191
254
|
|
|
255
|
+
function applyCrop() {
|
|
256
|
+
const canvas = cropper.getCroppedCanvas({
|
|
257
|
+
maxWidth: 2000,
|
|
258
|
+
maxHeight: 2000,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const croppedData = canvas.toDataURL('image/jpeg', 0.9);
|
|
262
|
+
imagePreview.src = croppedData;
|
|
263
|
+
imagePreview.style.display = 'block';
|
|
264
|
+
if (imagePlaceholder) imagePlaceholder.style.display = 'none';
|
|
265
|
+
imageTools.style.display = 'flex';
|
|
266
|
+
imageDataInput.value = croppedData;
|
|
267
|
+
|
|
268
|
+
ui('#cropper-dialog');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
imageInput.addEventListener('change', function(e) {
|
|
272
|
+
const files = e.target.files;
|
|
273
|
+
if (files && files.length > 0) {
|
|
274
|
+
const reader = new FileReader();
|
|
275
|
+
reader.onload = function(event) {
|
|
276
|
+
imagePreview.src = event.target.result;
|
|
277
|
+
imagePreview.style.display = 'block';
|
|
278
|
+
if (imagePlaceholder) imagePlaceholder.style.display = 'none';
|
|
279
|
+
imageTools.style.display = 'flex';
|
|
280
|
+
// Automatically open cropper for new images
|
|
281
|
+
openCropper();
|
|
282
|
+
};
|
|
283
|
+
reader.readAsDataURL(files[0]);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
192
287
|
document.addEventListener('DOMContentLoaded', function() {
|
|
193
288
|
const fields = ['id_description', 'id_ingredients', 'id_instructions'];
|
|
194
289
|
fields.forEach(id => {
|