sandwitches 2.4.0__py3-none-any.whl → 2.4.1__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
@@ -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 = ("id", "user", "recipe", "status", "total_price", "created_at")
88
- list_filter = ("status", "created_at")
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
@@ -86,6 +86,8 @@ class UserSignupForm(UserCreationForm, BaseUserFormMixin):
86
86
 
87
87
 
88
88
  class UserProfileForm(forms.ModelForm):
89
+ image_data = forms.CharField(widget=forms.HiddenInput(), required=False)
90
+
89
91
  class Meta:
90
92
  model = User
91
93
  fields = (
@@ -96,8 +98,25 @@ class UserProfileForm(forms.ModelForm):
96
98
  "bio",
97
99
  )
98
100
 
101
+ def save(self, commit=True):
102
+ user = super().save(commit=False)
103
+ image_data = self.cleaned_data.get("image_data")
104
+ if image_data and image_data.startswith("data:image"):
105
+ import base64
106
+ from django.core.files.base import ContentFile
107
+
108
+ format, imgstr = image_data.split(";base64,")
109
+ ext = format.split("/")[-1]
110
+ data = ContentFile(base64.b64decode(imgstr), name=f"avatar.{ext}")
111
+ user.avatar = data
112
+ if commit:
113
+ user.save()
114
+ return user
115
+
99
116
 
100
117
  class UserEditForm(forms.ModelForm):
118
+ image_data = forms.CharField(widget=forms.HiddenInput(), required=False)
119
+
101
120
  class Meta:
102
121
  model = User
103
122
  fields = (
@@ -112,6 +131,21 @@ class UserEditForm(forms.ModelForm):
112
131
  "bio",
113
132
  )
114
133
 
134
+ def save(self, commit=True):
135
+ user = super().save(commit=False)
136
+ image_data = self.cleaned_data.get("image_data")
137
+ if image_data and image_data.startswith("data:image"):
138
+ import base64
139
+ from django.core.files.base import ContentFile
140
+
141
+ format, imgstr = image_data.split(";base64,")
142
+ ext = format.split("/")[-1]
143
+ data = ContentFile(base64.b64decode(imgstr), name=f"avatar.{ext}")
144
+ user.avatar = data
145
+ if commit:
146
+ user.save()
147
+ return user
148
+
115
149
 
116
150
  class TagForm(forms.ModelForm):
117
151
  class Meta:
@@ -126,6 +160,7 @@ class RecipeForm(forms.ModelForm):
126
160
  widget=forms.TextInput(attrs={"placeholder": _("e.g. spicy, vegan, quick")}),
127
161
  )
128
162
  rotation = forms.IntegerField(widget=forms.HiddenInput(), initial=0, required=False)
163
+ image_data = forms.CharField(widget=forms.HiddenInput(), required=False)
129
164
 
130
165
  class Meta:
131
166
  model = Recipe
@@ -153,11 +188,22 @@ class RecipeForm(forms.ModelForm):
153
188
  )
154
189
 
155
190
  def save(self, commit=True):
156
- recipe = super().save(commit=commit)
191
+ recipe = super().save(commit=False)
192
+
193
+ # Handle base64 image data from cropper
194
+ image_data = self.cleaned_data.get("image_data")
195
+ if image_data and image_data.startswith("data:image"):
196
+ import base64
197
+ from django.core.files.base import ContentFile
157
198
 
158
- # Handle rotation if an image exists and rotation is requested
199
+ format, imgstr = image_data.split(";base64,")
200
+ ext = format.split("/")[-1]
201
+ data = ContentFile(base64.b64decode(imgstr), name=f"recipe_image.{ext}")
202
+ recipe.image = data
203
+
204
+ # Handle rotation if an image exists and rotation is requested (fallback for simple rotation)
159
205
  rotation = self.cleaned_data.get("rotation", 0)
160
- if rotation != 0 and recipe.image:
206
+ if rotation != 0 and recipe.image and not image_data:
161
207
  try:
162
208
  from PIL import Image as PILImage
163
209
 
@@ -169,9 +215,9 @@ class RecipeForm(forms.ModelForm):
169
215
  print(f"Error rotating image: {e}")
170
216
 
171
217
  if commit:
218
+ recipe.save()
172
219
  recipe.set_tags_from_string(self.cleaned_data.get("tags_string", ""))
173
220
  else:
174
- # We'll need to handle this in the view if commit=False
175
221
  self.save_m2m = lambda: recipe.set_tags_from_string(
176
222
  self.cleaned_data.get("tags_string", "")
177
223
  )
@@ -184,6 +230,7 @@ class UserRecipeSubmissionForm(forms.ModelForm):
184
230
  label=_("Tags (comma separated)"),
185
231
  widget=forms.TextInput(attrs={"placeholder": _("e.g. spicy, vegan, quick")}),
186
232
  )
233
+ image_data = forms.CharField(widget=forms.HiddenInput(), required=False)
187
234
 
188
235
  class Meta:
189
236
  model = Recipe
@@ -201,8 +248,21 @@ class UserRecipeSubmissionForm(forms.ModelForm):
201
248
  }
202
249
 
203
250
  def save(self, commit=True):
204
- recipe = super().save(commit=commit)
251
+ recipe = super().save(commit=False)
252
+
253
+ # Handle base64 image data from cropper
254
+ image_data = self.cleaned_data.get("image_data")
255
+ if image_data and image_data.startswith("data:image"):
256
+ import base64
257
+ from django.core.files.base import ContentFile
258
+
259
+ format, imgstr = image_data.split(";base64,")
260
+ ext = format.split("/")[-1]
261
+ data = ContentFile(base64.b64decode(imgstr), name=f"recipe_image.{ext}")
262
+ recipe.image = data
263
+
205
264
  if commit:
265
+ recipe.save()
206
266
  recipe.set_tags_from_string(self.cleaned_data.get("tags_string", ""))
207
267
  else:
208
268
  self.save_m2m = lambda: recipe.set_tags_from_string(
@@ -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
+ ]
sandwitches/models.py CHANGED
@@ -11,6 +11,7 @@ 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,7 +22,7 @@ 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)
@@ -34,6 +35,8 @@ class Setting(SingletonModel):
34
35
 
35
36
 
36
37
  class User(AbstractUser):
38
+ username = models.CharField(max_length=150, unique=True)
39
+ email = models.EmailField(validators=[EmailValidator()])
37
40
  avatar = models.ImageField(upload_to="avatars", blank=True, null=True)
38
41
  avatar_thumbnail = ImageSpecField(
39
42
  source="avatar",
@@ -230,6 +233,9 @@ class Rating(models.Model):
230
233
  class Order(models.Model):
231
234
  STATUS_CHOICES = (
232
235
  ("PENDING", "Pending"),
236
+ ("PREPARING", "Preparing"),
237
+ ("MADE", "Made"),
238
+ ("SHIPPED", "Shipped"),
233
239
  ("COMPLETED", "Completed"),
234
240
  ("CANCELLED", "Cancelled"),
235
241
  )
@@ -239,6 +245,7 @@ class Order(models.Model):
239
245
  )
240
246
  recipe = models.ForeignKey(Recipe, related_name="orders", on_delete=models.CASCADE)
241
247
  status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="PENDING")
248
+ completed = models.BooleanField(default=False)
242
249
  total_price = models.DecimalField(max_digits=6, decimal_places=2)
243
250
  created_at = models.DateTimeField(auto_now_add=True)
244
251
  updated_at = models.DateTimeField(auto_now=True)
@@ -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{% else %}error{% endif %}">
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; transition: transform 0.3s ease;">
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
- <div class="row no-space border round mb-1">
120
- <button type="button" class="button transparent max" onclick="rotatePreview(-90)" title="{% trans 'Rotate 90° CCW' %}">
121
- <i>rotate_left</i>
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 currentRotation = 0;
182
-
183
- function rotatePreview(angle) {
184
- currentRotation = (currentRotation + angle) % 360;
185
- const img = document.getElementById('image-preview');
186
- if (img) {
187
- img.style.transform = `rotate(${currentRotation}deg)`;
188
- document.getElementsByName('rotation')[0].value = currentRotation;
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 => {
@@ -4,6 +4,15 @@
4
4
  {% block title %}{% trans "Community" %}{% endblock %}
5
5
 
6
6
  {% block content %}
7
+ <style>
8
+ .cropper-container {
9
+ max-height: 70vh;
10
+ }
11
+ #cropper-image {
12
+ display: block;
13
+ max-width: 100%;
14
+ }
15
+ </style>
7
16
  <main class="responsive">
8
17
  <div class="large-space"></div>
9
18
  <article class="round s12 m10 l8 offset-m1 offset-l2 elevate">
@@ -15,6 +24,7 @@
15
24
 
16
25
  <form method="post" enctype="multipart/form-data">
17
26
  {% csrf_token %}
27
+ {{ form.image_data }}
18
28
 
19
29
  <div class="grid">
20
30
  <div class="s12">
@@ -53,15 +63,21 @@
53
63
 
54
64
  <!-- Image Upload with Preview -->
55
65
  <div class="s12 m6">
56
- <div class="field label border round">
66
+ <div class="field file label border round">
67
+ <input type="text" readonly>
57
68
  {{ form.image }}
58
69
  <label>{% trans "Image" %}</label>
70
+ <i>publish</i>
59
71
  </div>
60
72
  </div>
61
73
  <div class="s12 m6 center-align relative">
62
- <div class="padding border round dashed surface-variant" style="min-height: 150px; display: flex; align-items: center; justify-content: center;">
63
- <img id="image-preview" src="{% if recipe.image %}{{ recipe.image.url }}{% endif %}" class="responsive round" style="max-height: 200px; {% if not recipe.image %}display:none;{% endif %}">
74
+ <div class="padding border round dashed surface-variant" style="min-height: 150px; display: flex; align-items: center; justify-content: center; flex-direction: column;">
75
+ <img id="image-preview" src="{% if recipe.image %}{{ recipe.image.url }}{% endif %}" class="responsive round mb-1" style="max-height: 200px; {% if not recipe.image %}display:none;{% endif %}">
64
76
  <span id="image-placeholder" class="gray-text" {% if recipe.image %}style="display:none;"{% endif %}>{% trans "Image Preview" %}</span>
77
+ <button type="button" id="edit-image-btn" class="button transparent border round" style="display: none;" onclick="openCropper()">
78
+ <i>crop_rotate</i>
79
+ <span>{% trans "Edit Image" %}</span>
80
+ </button>
65
81
  </div>
66
82
  </div>
67
83
 
@@ -101,6 +117,41 @@
101
117
  </div>
102
118
  </article>
103
119
 
120
+ <dialog id="cropper-dialog" class="large">
121
+ <div class="padding">
122
+ <h5 class="bold mb-1">{% trans "Edit Image" %}</h5>
123
+ <div class="cropper-container mb-1">
124
+ <img id="cropper-image" src="">
125
+ </div>
126
+ <div class="row scroll no-space border round mb-1">
127
+ <button type="button" class="button transparent max" onclick="cropper.rotate(-90)" title="{% trans 'Rotate Left' %}">
128
+ <i>rotate_left</i>
129
+ </button>
130
+ <button type="button" class="button transparent max" onclick="cropper.rotate(90)" title="{% trans 'Rotate Right' %}">
131
+ <i>rotate_right</i>
132
+ </button>
133
+ <div class="divider vertical"></div>
134
+ <button type="button" class="button transparent max" onclick="cropper.scaleX(-cropper.getData().scaleX || -1)" title="{% trans 'Flip Horizontal' %}">
135
+ <i>flip</i>
136
+ </button>
137
+ <button type="button" class="button transparent max" onclick="cropper.scaleY(-cropper.getData().scaleY || -1)" title="{% trans 'Flip Vertical' %}">
138
+ <i>flip</i>
139
+ </button>
140
+ <div class="divider vertical"></div>
141
+ <button type="button" class="button transparent max" onclick="cropper.setAspectRatio(1)" title="{% trans '1:1' %}">1:1</button>
142
+ <button type="button" class="button transparent max" onclick="cropper.setAspectRatio(4/3)" title="{% trans '4:3' %}">4:3</button>
143
+ <button type="button" class="button transparent max" onclick="cropper.setAspectRatio(16/9)" title="{% trans '16:9' %}">16:9</button>
144
+ <button type="button" class="button transparent max" onclick="cropper.setAspectRatio(NaN)" title="{% trans 'Free' %}">
145
+ <i>crop_free</i>
146
+ </button>
147
+ </div>
148
+ <nav class="right-align">
149
+ <button type="button" class="button transparent" onclick="ui('#cropper-dialog')">{% trans "Cancel" %}</button>
150
+ <button type="button" class="button primary" onclick="applyCrop()">{% trans "Apply" %}</button>
151
+ </nav>
152
+ </div>
153
+ </dialog>
154
+
104
155
 
105
156
  <div class="large-space"></div>
106
157
 
@@ -117,21 +168,67 @@
117
168
 
118
169
  {% block page_scripts %}
119
170
  <script>
120
- // Image Preview Logic
121
- var imageInput = document.querySelector('input[type="file"]');
171
+ let cropper;
172
+ const imageInput = document.querySelector('input[type="file"]');
173
+ const imagePreview = document.getElementById('image-preview');
174
+ const imagePlaceholder = document.getElementById('image-placeholder');
175
+ const editImageBtn = document.getElementById('edit-image-btn');
176
+ const imageDataInput = document.getElementsByName('image_data')[0];
177
+ const cropperImage = document.getElementById('cropper-image');
178
+
179
+ function openCropper() {
180
+ if (!imagePreview.src || imagePreview.src === window.location.href) return;
181
+ cropperImage.src = imagePreview.src;
182
+ ui('#cropper-dialog');
183
+
184
+ if (cropper) {
185
+ cropper.destroy();
186
+ }
187
+
188
+ setTimeout(() => {
189
+ cropper = new Cropper(cropperImage, {
190
+ viewMode: 1,
191
+ autoCropArea: 1,
192
+ responsive: true,
193
+ restore: false,
194
+ checkCrossOrigin: true,
195
+ guides: true,
196
+ center: true,
197
+ highlight: false,
198
+ cropBoxMovable: true,
199
+ cropBoxResizable: true,
200
+ toggleDragModeOnDblclick: false,
201
+ });
202
+ }, 100);
203
+ }
204
+
205
+ function applyCrop() {
206
+ const canvas = cropper.getCroppedCanvas({
207
+ maxWidth: 2000,
208
+ maxHeight: 2000,
209
+ });
210
+
211
+ const croppedData = canvas.toDataURL('image/jpeg', 0.9);
212
+ imagePreview.src = croppedData;
213
+ imagePreview.style.display = 'block';
214
+ if (imagePlaceholder) imagePlaceholder.style.display = 'none';
215
+ if (editImageBtn) editImageBtn.style.display = 'inline-flex';
216
+ imageDataInput.value = croppedData;
217
+
218
+ ui('#cropper-dialog');
219
+ }
220
+
122
221
  if (imageInput) {
123
222
  imageInput.onchange = function (evt) {
124
- var tgt = evt.target || window.event.srcElement,
125
- files = tgt.files;
126
-
223
+ const files = evt.target.files;
127
224
  if (FileReader && files && files.length) {
128
- var fr = new FileReader();
225
+ const fr = new FileReader();
129
226
  fr.onload = function () {
130
- var preview = document.getElementById('image-preview');
131
- var placeholder = document.getElementById('image-placeholder');
132
- preview.src = fr.result;
133
- preview.style.display = 'block';
134
- if(placeholder) placeholder.style.display = 'none';
227
+ imagePreview.src = fr.result;
228
+ imagePreview.style.display = 'block';
229
+ if (imagePlaceholder) imagePlaceholder.style.display = 'none';
230
+ if (editImageBtn) editImageBtn.style.display = 'inline-flex';
231
+ openCropper();
135
232
  }
136
233
  fr.readAsDataURL(files[0]);
137
234
  }
@@ -0,0 +1,68 @@
1
+ {% extends "base_beer.html" %}
2
+ {% load static i18n %}
3
+
4
+ {% block title %}{% trans "Order Details" %} #{{ order.id }}{% endblock %}
5
+
6
+ {% block content %}
7
+ <div class="large-space"></div>
8
+
9
+ <div class="grid">
10
+ <div class="s12 m10 l8 xl6 middle-align center-align" style="margin: 0 auto;">
11
+ <article class="round elevate left-align">
12
+ <div class="padding">
13
+ <nav>
14
+ <a href="{% url 'user_profile' %}" class="button transparent circle">
15
+ <i>arrow_back</i>
16
+ </a>
17
+ <h5 class="max">{% trans "Order" %} #{{ order.id }}</h5>
18
+ <span class="chip {% if order.status == 'PENDING' %}surface-variant{% elif order.status == 'COMPLETED' %}primary{% elif order.status == 'CANCELLED' %}error{% else %}secondary{% endif %}">
19
+ {{ order.get_status_display }}
20
+ </span>
21
+ </nav>
22
+ <div class="divider"></div>
23
+
24
+ <div class="grid">
25
+ <div class="s12 m6">
26
+ <p class="small-text">{% trans "Date Ordered" %}</p>
27
+ <p>{{ order.created_at|date:"d F Y, H:i" }}</p>
28
+ </div>
29
+ <div class="s12 m6">
30
+ <p class="small-text">{% trans "Last Update" %}</p>
31
+ <p>{{ order.updated_at|date:"d F Y, H:i" }}</p>
32
+ </div>
33
+ </div>
34
+
35
+ <div class="divider"></div>
36
+
37
+ <h6>{% trans "Item Details" %}</h6>
38
+ <div class="row">
39
+ <div class="max">
40
+ <a href="{% url 'recipe_detail' order.recipe.slug %}" class="bold primary-text">{{ order.recipe.title }}</a>
41
+ <p class="small-text">{{ order.recipe.description|truncatewords:20 }}</p>
42
+ </div>
43
+ <div class="min">
44
+ <span class="bold">{{ order.total_price }} €</span>
45
+ </div>
46
+ </div>
47
+
48
+ {% if order.recipe.image %}
49
+ <div class="space"></div>
50
+ <img src="{{ order.recipe.image.url }}" class="responsive round border" alt="{{ order.recipe.title }}">
51
+ {% endif %}
52
+
53
+ <div class="divider"></div>
54
+
55
+ <div class="row">
56
+ <div class="max text-right">
57
+ <span class="bold">{% trans "Total" %}</span>
58
+ </div>
59
+ <div class="min">
60
+ <span class="bold text-primary">{{ order.total_price }} €</span>
61
+ </div>
62
+ </div>
63
+
64
+ </div>
65
+ </article>
66
+ </div>
67
+ </div>
68
+ {% endblock %}
@@ -90,6 +90,96 @@
90
90
 
91
91
  </form>
92
92
  </article>
93
+
94
+ <div class="large-space"></div>
95
+
96
+ <h4 class="center-align primary-text">{% trans "Order History" %}</h4>
97
+
98
+ <form method="get" class="row no-wrap middle-align">
99
+ <div class="field label border round small">
100
+ <select name="status" onchange="this.form.submit()">
101
+ <option value="">{% trans "All Statuses" %}</option>
102
+ {% for code, label in status_choices %}
103
+ <option value="{{ code }}" {% if current_status == code %}selected{% endif %}>{{ label }}</option>
104
+ {% endfor %}
105
+ </select>
106
+ <label>{% trans "Filter by Status" %}</label>
107
+ </div>
108
+ <div class="space"></div>
109
+ <div class="field label border round small">
110
+ <select name="sort" onchange="this.form.submit()">
111
+ <option value="date_desc" {% if current_sort == 'date_desc' %}selected{% endif %}>{% trans "Newest First" %}</option>
112
+ <option value="date_asc" {% if current_sort == 'date_asc' %}selected{% endif %}>{% trans "Oldest First" %}</option>
113
+ <option value="price_desc" {% if current_sort == 'price_desc' %}selected{% endif %}>{% trans "Price: High to Low" %}</option>
114
+ <option value="price_asc" {% if current_sort == 'price_asc' %}selected{% endif %}>{% trans "Price: Low to High" %}</option>
115
+ </select>
116
+ <label>{% trans "Sort by" %}</label>
117
+ </div>
118
+ </form>
119
+
120
+ {% if orders %}
121
+ <div class="padding border round surface left-align">
122
+ <table class="border striped">
123
+ <thead>
124
+ <tr>
125
+ <th class="min">#</th>
126
+ <th class="max">{% trans "Recipe" %}</th>
127
+ <th class="min">{% trans "Date" %}</th>
128
+ <th class="min">{% trans "Status" %}</th>
129
+ <th class="min">{% trans "Price" %}</th>
130
+ <th class="min"></th>
131
+ </tr>
132
+ </thead>
133
+ <tbody>
134
+ {% for order in orders %}
135
+ <tr>
136
+ <td>{{ order.id }}</td>
137
+ <td>
138
+ <a href="{% url 'recipe_detail' order.recipe.slug %}">{{ order.recipe.title }}</a>
139
+ </td>
140
+ <td class="no-wrap">{{ order.created_at|date:"d/m/Y" }}</td>
141
+ <td>
142
+ <span class="chip tiny {% if order.status == 'PENDING' %}surface-variant{% elif order.status == 'COMPLETED' %}primary{% elif order.status == 'CANCELLED' %}error{% else %}secondary{% endif %}">
143
+ {{ order.get_status_display }}
144
+ </span>
145
+ </td>
146
+ <td class="no-wrap">{{ order.total_price }} €</td>
147
+ <td>
148
+ <a href="{% url 'user_order_detail' order.id %}" class="button circle transparent small">
149
+ <i>visibility</i>
150
+ </a>
151
+ </td>
152
+ </tr>
153
+ {% endfor %}
154
+ </tbody>
155
+ </table>
156
+ </div>
157
+
158
+ {% if orders.paginator.num_pages > 1 %}
159
+ <div class="center-align padding">
160
+ <nav class="row">
161
+ {% if orders.has_previous %}
162
+ <a href="?page={{ orders.previous_page_number }}{% if current_status %}&status={{ current_status }}{% endif %}{% if current_sort %}&sort={{ current_sort }}{% endif %}" class="button transparent circle"><i>chevron_left</i></a>
163
+ {% else %}
164
+ <button class="button transparent circle" disabled><i>chevron_left</i></button>
165
+ {% endif %}
166
+
167
+ <span class="padding">{{ orders.number }} / {{ orders.paginator.num_pages }}</span>
168
+
169
+ {% if orders.has_next %}
170
+ <a href="?page={{ orders.next_page_number }}{% if current_status %}&status={{ current_status }}{% endif %}{% if current_sort %}&sort={{ current_sort }}{% endif %}" class="button transparent circle"><i>chevron_right</i></a>
171
+ {% else %}
172
+ <button class="button transparent circle" disabled><i>chevron_right</i></button>
173
+ {% endif %}
174
+ </nav>
175
+ </div>
176
+ {% endif %}
177
+
178
+ {% else %}
179
+ <div class="padding border round surface">
180
+ <p class="center-align">{% trans "No previous orders found." %}</p>
181
+ </div>
182
+ {% endif %}
93
183
  </div>
94
184
  </div>
95
185
  {% endblock %}
sandwitches/urls.py CHANGED
@@ -34,6 +34,7 @@ urlpatterns = [
34
34
  path("login/", views.CustomLoginView.as_view(), name="login"),
35
35
  path("logout/", LogoutView.as_view(next_page="index"), name="logout"),
36
36
  path("profile/", views.user_profile, name="user_profile"),
37
+ path("orders/<int:pk>/", views.user_order_detail, name="user_order_detail"),
37
38
  path("community/", views.community, name="community"),
38
39
  path("admin/", admin.site.urls),
39
40
  path("api/", api.urls),
sandwitches/views.py CHANGED
@@ -28,6 +28,7 @@ from PIL import Image
28
28
  from django.db.models import Q, Avg
29
29
  from django_tasks.backends.database.models import DBTaskResult
30
30
  from django.contrib.auth.views import LoginView
31
+ from django.core.paginator import Paginator
31
32
 
32
33
 
33
34
  from sandwitches import __version__ as sandwitches_version
@@ -858,8 +859,52 @@ def user_profile(request):
858
859
  return redirect("user_profile")
859
860
  else:
860
861
  form = UserProfileForm(instance=request.user)
862
+
863
+ orders = request.user.orders.select_related("recipe").all()
864
+
865
+ # Filtering
866
+ status_filter = request.GET.get("status")
867
+ if status_filter:
868
+ orders = orders.filter(status=status_filter)
869
+
870
+ # Sorting
871
+ sort_param = request.GET.get("sort", "-created_at")
872
+ allowed_sorts = {
873
+ "date_asc": "created_at",
874
+ "date_desc": "-created_at",
875
+ "price_asc": "total_price",
876
+ "price_desc": "-total_price",
877
+ "status": "status",
878
+ }
879
+ order_by = allowed_sorts.get(sort_param, "-created_at")
880
+ orders = orders.order_by(order_by)
881
+
882
+ # Pagination
883
+ paginator = Paginator(orders, 5) # Show 5 orders per page
884
+ page_number = request.GET.get("page")
885
+ page_obj = paginator.get_page(page_number)
886
+
861
887
  return render(
862
- request, "profile.html", {"form": form, "version": sandwitches_version}
888
+ request,
889
+ "profile.html",
890
+ {
891
+ "form": form,
892
+ "version": sandwitches_version,
893
+ "orders": page_obj,
894
+ "current_status": status_filter,
895
+ "current_sort": sort_param,
896
+ "status_choices": Order.STATUS_CHOICES,
897
+ },
898
+ )
899
+
900
+
901
+ @login_required
902
+ def user_order_detail(request, pk):
903
+ order = get_object_or_404(Order, pk=pk, user=request.user)
904
+ return render(
905
+ request,
906
+ "order_detail.html",
907
+ {"order": order, "version": sandwitches_version},
863
908
  )
864
909
 
865
910
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: sandwitches
3
- Version: 2.4.0
3
+ Version: 2.4.1
4
4
  Summary: Add your description here
5
5
  Author: Martyn van Dijke
6
6
  Author-email: Martyn van Dijke <martijnvdijke600@gmail.com>
@@ -1,9 +1,9 @@
1
1
  sandwitches/__init__.py,sha256=YTDsQDSdJmxV2Z0dTbBqZhuJRuXcNLSKL0SX73Lu2u8,195
2
- sandwitches/admin.py,sha256=-02WqE8U3rxrVCoNB7sfvtyE4v_e3pt7mFwXfUlindo,2421
2
+ sandwitches/admin.py,sha256=-4QA5InEvLHyb6VFAGKapWbJO9mXdiV4GeQcGsM4xlI,2510
3
3
  sandwitches/api.py,sha256=ruD5QeOPY-l9PvkJQiaOYoI0sRARDpqpFrFDgBxo9cQ,6389
4
4
  sandwitches/asgi.py,sha256=cygnXdXSSVspM7ZXuj47Ef6oz7HSTw4D7BPzgE2PU5w,399
5
5
  sandwitches/feeds.py,sha256=iz1d11dV0utA0ZNsB7VIAp0h8Zr5mFNSKJWHbw_j6YM,683
6
- sandwitches/forms.py,sha256=YvkSTa9h_ag_b58ToOHCQIHBa3VeHMC9RKB9F7qI-gk,7152
6
+ sandwitches/forms.py,sha256=VoQ81COBCgwuS161dhF8IVeeAp8dLiQakzs56SV6-T8,9541
7
7
  sandwitches/locale/nl/LC_MESSAGES/django.mo,sha256=EzQWzIhz_Na3w9AS7F-YjB-Xv63t4sMRSAkEQ1-g32M,5965
8
8
  sandwitches/locale/nl/LC_MESSAGES/django.po,sha256=znxspEoMwkmktusZtbVrt1KG1LDUwIEi4ZEIE3XGeoI,25904
9
9
  sandwitches/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -23,8 +23,9 @@ sandwitches/migrations/0011_alter_historicalrecipe_is_community_made_and_more.py
23
23
  sandwitches/migrations/0012_rename_is_community_made_historicalrecipe_is_approved_and_more.py,sha256=bCDPpHmZTIW70-YeL30WhuJ2mORktkrsntKqTw0vj94,577
24
24
  sandwitches/migrations/0013_cartitem.py,sha256=KYMinpnZiLHwjo7p7EdJHQExuEGC9jtpcZcbm1r7JFo,1787
25
25
  sandwitches/migrations/0014_ensure_groups_exist.py,sha256=5FSA742bEQtwHZl5CWZQYIdmS8FBxMgWS079dOaOltY,564
26
+ sandwitches/migrations/0015_order_completed_alter_order_status_and_more.py,sha256=PTXQZUE8RqTAK8l0vkZhiGKv2T0PDiWEue7f6qz3AQ0,1670
26
27
  sandwitches/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
27
- sandwitches/models.py,sha256=ptEdQP4oOvyjdhYQlTtnZD-3eIUAY-8nU17G-hmN_2I,10796
28
+ sandwitches/models.py,sha256=kU71fC9Lf6Ij_8vpN0nB0OUKWpzWxi9WxqIYEzP4Ogg,11144
28
29
  sandwitches/settings.py,sha256=5_eQAJCAV093hnhr3XOxHekT4IF-PEJcRiTecq71_SQ,5841
29
30
  sandwitches/storage.py,sha256=ibBG6tVtArqzgEKsRimZPwsqW7i9j4WiPLLHrOJchow,3578
30
31
  sandwitches/tasks.py,sha256=YiliAT2rj0fh7hrwKq5_qWtv9AGhd5iulj_iBwZBBKg,6024
@@ -33,10 +34,10 @@ sandwitches/templates/admin/confirm_delete.html,sha256=HfsZI_gV8JQTKz215TYgPWBrg
33
34
  sandwitches/templates/admin/dashboard.html,sha256=Ial8zH2odIPpstSkQmzGrasl0QxvgGhFPAGy7V5xRzY,5916
34
35
  sandwitches/templates/admin/order_list.html,sha256=eHFUn2speXaaj5_SFUG0Z0HfWVUR9-VCDRBeb8ufFb0,819
35
36
  sandwitches/templates/admin/partials/dashboard_charts.html,sha256=NYrt-LDZO4__2KDWhAYL5K_f-2Zgj0iiuaZQiRZlBWg,3639
36
- sandwitches/templates/admin/partials/order_rows.html,sha256=Ye35liahKbQ3rqa6fIGSTwb7seoXoqyqSw0wyNq2C_o,893
37
+ sandwitches/templates/admin/partials/order_rows.html,sha256=C7_ArHw1udaGjx6CRJHhksje0OReP7UUhdsHcdFPqCc,940
37
38
  sandwitches/templates/admin/rating_list.html,sha256=8CHAsBfKfs4izhb-IyOiDjJXqAZxFcStoRSGh4pRlgM,1365
38
39
  sandwitches/templates/admin/recipe_approval_list.html,sha256=M6GFYI45lAkLkvqP44cu5tDYVOeeVNklEphof1euesM,2281
39
- sandwitches/templates/admin/recipe_form.html,sha256=23wHT4hs128xnv2nkS6AtcKzY3sblia_dGVNnaeIp5Y,8734
40
+ sandwitches/templates/admin/recipe_form.html,sha256=wVKKBFl3vN11aknnmv2Hxkj66zZk9iZ0x_iS1j_X_Ro,12884
40
41
  sandwitches/templates/admin/recipe_list.html,sha256=5fGnRIQ7JfvM3yfG-sngEIEgiPnPDkjK1Tn3nO8EDh4,5359
41
42
  sandwitches/templates/admin/tag_form.html,sha256=JRWgAl4fz_Oy-Kuo1K6Mex_CXdsHMABzzyPazthr1Kg,989
42
43
  sandwitches/templates/admin/tag_list.html,sha256=ttxwXgfdxkEs4Cmrz5RHaGmaqLd7JDmWhjv80XIQqyw,1246
@@ -47,7 +48,7 @@ sandwitches/templates/admin/user_list.html,sha256=6O1YctULY-tqJnagybJof9ERA_NL1L
47
48
  sandwitches/templates/base.html,sha256=mwCESNirfvvdyMg2e1Siy_LA8fLH29m0aS_Jv0Qom4U,3597
48
49
  sandwitches/templates/base_beer.html,sha256=4QgU4_gu_RRMtimmRAhATDJ3mj_WANxtilQJYNgAL60,2077
49
50
  sandwitches/templates/cart.html,sha256=YqmrzOLLPAXSqeXeUTrt9AwTTWOitOLTaD_k3mYYVpM,4537
50
- sandwitches/templates/community.html,sha256=6x-Z8E0W3Ii-d0aG7DdCJoWQM9bVKNP_NSP8fTqpo6o,5324
51
+ sandwitches/templates/community.html,sha256=-YhpPtLbrVK9mc2Go1XBInLK-7OXrtb7kKukjl7rGbg,9607
51
52
  sandwitches/templates/components/carousel_scripts.html,sha256=9vEL5JJv8zUUjEtsnHW-BwwXUNWqQ6w_vf6UdxgEv_I,1934
52
53
  sandwitches/templates/components/favorites_search_form.html,sha256=tpD8SpS47TUDJBwxhMuvjhTN9pjWoRGFW50TBv48Ld4,5202
53
54
  sandwitches/templates/components/footer.html,sha256=Qk-myRtXS6-1b3fMowVGnSuFb_UkUgX6BYX9zgh_SV8,486
@@ -66,17 +67,18 @@ sandwitches/templates/detail.html,sha256=g-O_RsW9Ix9ivWC0nZ4FwHY2NhgYZ3bEGLpqGY0
66
67
  sandwitches/templates/favorites.html,sha256=0cPpW07N6Isrb8XpvA5Eh97L2-12QFZ43EzeJvbOlXo,917
67
68
  sandwitches/templates/index.html,sha256=7anU7k8s80JYk59Rwsm8EdlNYd7B5clCvV7pKq2IUy0,2518
68
69
  sandwitches/templates/login.html,sha256=LiQskhkOkfx0EE4ssA1ToqQ3oEll08OPYLDIkLjHfU8,2177
70
+ sandwitches/templates/order_detail.html,sha256=D6MjUVibQuED2VRNHSjKVnLHcLgFtLvcVmuwlzfoJzo,2498
69
71
  sandwitches/templates/partials/recipe_list.html,sha256=LUHKFKG90D72K9X2X3d1osvj2jX1QU_MbPe0lNwRSII,4555
70
- sandwitches/templates/profile.html,sha256=PQTL6_xn0pGUxqEOYuz5j0pmqAyG0Wr3KvyFBO8_k1s,4156
72
+ sandwitches/templates/profile.html,sha256=m3-31b_z5QhHLgol-QwHrb3MM9B9kk5kybL3B1nWC5Y,8539
71
73
  sandwitches/templates/setup.html,sha256=iNveFgePATsCSO4XMbGPa8TnWHyvj8S_5WwcW6i7pbo,4661
72
74
  sandwitches/templates/signup.html,sha256=pNBSlRGZI_B5ccF3dWpUgWBcjODkdLlq7HhyJLYIHCI,6176
73
75
  sandwitches/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
74
76
  sandwitches/templatetags/custom_filters.py,sha256=0KDFlFz4b5LwlcURBAmzyYWKKea-LwydZytJGVkkuKA,243
75
77
  sandwitches/templatetags/markdown_extras.py,sha256=0ibmRzxE3r85x4k7kK71R-9UT0CgeegYF7MHzj3juTI,344
76
- sandwitches/urls.py,sha256=1GyqdrWsCIbKN8wsT4eeE98blx-bBHc6tJgvXzta2nc,4859
78
+ sandwitches/urls.py,sha256=TysY6JhOV2kGC-9KloZjLDfk4TUZS6xX95A_RfG971g,4940
77
79
  sandwitches/utils.py,sha256=SJP-TkeRZ0OIfaMigYrOSbxRqYXswoqoWhwll3nFuAM,7245
78
- sandwitches/views.py,sha256=WF17_nRo6wDdC8oVjGBcPHGRtoi_Ji7x8W9HdcjAyQA,30890
80
+ sandwitches/views.py,sha256=pDDDXaojPstj5E_k5gBQmW901Lol0R3gUl3qWGwUmI0,32145
79
81
  sandwitches/wsgi.py,sha256=Eyncpnahq_4s3Lr9ruB-R3Lu9j9zBXqgPbUj7qhIbwU,399
80
- sandwitches-2.4.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
81
- sandwitches-2.4.0.dist-info/METADATA,sha256=E9TYl5ZmaC1n9_NM-va3fhaloK8HkrYiP_rei8B_DhE,3111
82
- sandwitches-2.4.0.dist-info/RECORD,,
82
+ sandwitches-2.4.1.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
83
+ sandwitches-2.4.1.dist-info/METADATA,sha256=97JvkhJ7rzJpQIfULxJ-0yz3rN1EfGZkhB-zpihx4wg,3111
84
+ sandwitches-2.4.1.dist-info/RECORD,,