sandwitches 1.0.3__py3-none-any.whl → 1.2.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/models.py CHANGED
@@ -1,11 +1,42 @@
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
5
+ from django.contrib.auth import get_user_model
6
+ from django.db.models import Avg
7
+ from .tasks import email_users
8
+ from django.conf import settings
9
+ import logging
10
+ from django.urls import reverse
11
+
12
+ from imagekit.models import ImageSpecField
13
+ from imagekit.processors import ResizeToFill
6
14
 
7
15
  hashed_storage = HashedFilenameStorage()
8
16
 
17
+ User = get_user_model()
18
+
19
+
20
+ class Profile(models.Model):
21
+ user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
22
+ avatar = models.ImageField(upload_to="avatars", blank=True, null=True)
23
+ avatar_thumbnail = ImageSpecField(
24
+ source="avatar",
25
+ processors=[ResizeToFill(100, 50)],
26
+ format="JPEG",
27
+ options={"quality": 60},
28
+ )
29
+ bio = models.TextField(blank=True)
30
+ created_at = models.DateTimeField(auto_now_add=True)
31
+ updated_at = models.DateTimeField(auto_now=True)
32
+
33
+ class Meta:
34
+ verbose_name = "Profile"
35
+ verbose_name_plural = "Profiles"
36
+
37
+ def __str__(self):
38
+ return f"{self.user.username}'s Profile" # ty:ignore[possibly-missing-attribute]
39
+
9
40
 
10
41
  class Tag(models.Model):
11
42
  name = models.CharField(max_length=50, unique=True)
@@ -21,7 +52,7 @@ class Tag(models.Model):
21
52
  base = slugify(self.name)[:55]
22
53
  slug = base
23
54
  n = 1
24
- while Tag.objects.filter(slug=slug).exclude(pk=self.pk).exists():
55
+ while Tag.objects.filter(slug=slug).exclude(pk=self.pk).exists(): # ty:ignore[unresolved-attribute]
25
56
  slug = f"{base}-{n}"
26
57
  n += 1
27
58
  self.slug = slug
@@ -37,16 +68,44 @@ class Recipe(models.Model):
37
68
  description = models.TextField(blank=True)
38
69
  ingredients = models.TextField(blank=True)
39
70
  instructions = models.TextField(blank=True)
71
+ uploaded_by = models.ForeignKey(
72
+ User,
73
+ related_name="recipes",
74
+ on_delete=models.SET_NULL,
75
+ null=True,
76
+ blank=True,
77
+ )
40
78
  image = models.ImageField(
41
- upload_to="recipes/", # storage will replace with hashed path
79
+ upload_to="recipes/",
42
80
  storage=hashed_storage,
43
81
  blank=True,
44
82
  null=True,
45
83
  )
46
-
47
- # ManyToMany: tags are reusable and shared between recipes
84
+ image_thumbnail = ImageSpecField(
85
+ source="image",
86
+ processors=[ResizeToFill(150, 150)],
87
+ format="JPEG",
88
+ options={"quality": 70},
89
+ )
90
+ image_small = ImageSpecField(
91
+ source="image",
92
+ processors=[ResizeToFill(400, 300)],
93
+ format="JPEG",
94
+ options={"quality": 75},
95
+ )
96
+ image_medium = ImageSpecField(
97
+ source="image",
98
+ processors=[ResizeToFill(700, 500)],
99
+ format="JPEG",
100
+ options={"quality": 85},
101
+ )
102
+ image_large = ImageSpecField(
103
+ source="image",
104
+ processors=[ResizeToFill(1200, 800)],
105
+ format="JPEG",
106
+ options={"quality": 95},
107
+ )
48
108
  tags = models.ManyToManyField(Tag, blank=True, related_name="recipes")
49
-
50
109
  created_at = models.DateTimeField(auto_now_add=True)
51
110
  updated_at = models.DateTimeField(auto_now=True)
52
111
  history = HistoricalRecords()
@@ -57,19 +116,39 @@ class Recipe(models.Model):
57
116
  verbose_name_plural = "Recipes"
58
117
 
59
118
  def save(self, *args, **kwargs):
119
+ is_new = self._state.adding
120
+
60
121
  if not self.slug:
61
122
  base = slugify(self.title)[:240]
62
123
  slug = base
63
124
  n = 1
64
- while Recipe.objects.filter(slug=slug).exclude(pk=self.pk).exists():
125
+ while Recipe.objects.filter(slug=slug).exclude(pk=self.pk).exists(): # ty:ignore[unresolved-attribute]
65
126
  slug = f"{base}-{n}"
66
127
  n += 1
67
128
  self.slug = slug
129
+
68
130
  super().save(*args, **kwargs)
69
131
 
132
+ send_email = getattr(settings, "SEND_EMAIL")
133
+ logging.debug(f"SEND_EMAIL is set to {send_email}")
134
+
135
+ if is_new or settings.DEBUG:
136
+ if send_email:
137
+ email_users.enqueue(recipe_id=self.pk)
138
+ else:
139
+ logging.warning(
140
+ "Email sending is disabled; not sending email notification, make sure SEND_EMAIL is set to True in settings."
141
+ )
142
+ else:
143
+ logging.debug(
144
+ "Existing recipe saved (update); skipping email notification."
145
+ )
146
+
147
+ def get_absolute_url(self):
148
+ return reverse("recipe_detail", kwargs={"slug": self.slug})
149
+
70
150
  def tag_list(self):
71
- # returns list of tag names
72
- return list(self.tags.values_list("name", flat=True))
151
+ return list(self.tags.values_list("name", flat=True)) # ty:ignore[possibly-missing-attribute]
73
152
 
74
153
  def set_tags_from_string(self, tag_string):
75
154
  """
@@ -79,16 +158,34 @@ class Recipe(models.Model):
79
158
  names = [t.strip() for t in (tag_string or "").split(",") if t.strip()]
80
159
  tags = []
81
160
  for name in names:
82
- tag = Tag.objects.filter(name__iexact=name).first()
161
+ tag = Tag.objects.filter(name__iexact=name).first() # ty:ignore[unresolved-attribute]
83
162
  if not tag:
84
- tag = Tag.objects.create(name=name)
163
+ tag = Tag.objects.create(name=name) # ty:ignore[unresolved-attribute]
85
164
  tags.append(tag)
86
- # replace existing tags with these
87
- self.tags.set(tags)
88
- return self.tags.all()
165
+ self.tags.set(tags) # ty:ignore[possibly-missing-attribute]
166
+ return self.tags.all() # ty:ignore[possibly-missing-attribute]
89
167
 
90
- def get_absolute_url(self):
91
- return reverse("recipe_detail", kwargs={"pk": self.pk, "slug": self.slug})
168
+ def average_rating(self):
169
+ agg = self.ratings.aggregate(avg=Avg("score")) # ty:ignore[unresolved-attribute]
170
+ return agg["avg"] or 0
171
+
172
+ def rating_count(self):
173
+ return self.ratings.count() # ty:ignore[unresolved-attribute]
92
174
 
93
175
  def __str__(self):
94
176
  return self.title
177
+
178
+
179
+ class Rating(models.Model):
180
+ recipe = models.ForeignKey(Recipe, related_name="ratings", on_delete=models.CASCADE)
181
+ user = models.ForeignKey(User, related_name="ratings", on_delete=models.CASCADE)
182
+ score = models.PositiveSmallIntegerField(choices=[(i, i) for i in range(1, 6)])
183
+ created_at = models.DateTimeField(auto_now_add=True)
184
+ updated_at = models.DateTimeField(auto_now=True)
185
+
186
+ class Meta:
187
+ unique_together = ("recipe", "user")
188
+ ordering = ("-updated_at",)
189
+
190
+ def __str__(self):
191
+ return f"{self.recipe} — {self.score} by {self.user}"
sandwitches/settings.py CHANGED
@@ -12,18 +12,47 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
12
12
 
13
13
  from pathlib import Path
14
14
  import os
15
+ from django.core.exceptions import ImproperlyConfigured
16
+ from . import storage
15
17
 
18
+ DEBUG = bool(os.environ.get("DEBUG", default=0)) # ty:ignore[no-matching-overload]
16
19
 
17
20
  SECRET_KEY = os.environ.get("SECRET_KEY")
18
- DEBUG = bool(os.environ.get("DEBUG", default=0))
19
- ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "127.0.0.1").split(",")
21
+ if not SECRET_KEY:
22
+ raise ImproperlyConfigured(
23
+ "The SECRET_KEY environment variable must be set in production."
24
+ )
25
+
26
+ ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "127.0.0.1, localhost").split(",")
20
27
  CSRF_TRUSTED_ORIGINS = os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",")
28
+ DATABASE_FILE = Path(os.environ.get("DATABASE_FILE", default="/db/db.sqlite3")) # ty:ignore[no-matching-overload]
29
+
30
+ storage.is_database_readable(DATABASE_FILE)
31
+ storage.is_database_writable(DATABASE_FILE)
32
+
21
33
 
22
34
  # Build paths inside the project like this: BASE_DIR / 'subdir'.
23
35
  BASE_DIR = Path(__file__).resolve().parent.parent
24
36
 
25
- # Application definition
26
37
 
38
+ TASKS = {
39
+ "default": {
40
+ "BACKEND": "django_tasks.backends.database.DatabaseBackend",
41
+ "QUEUES": ["default", "emails"],
42
+ "OPTIONS": {
43
+ "queues": {
44
+ "low_priority": {
45
+ "max_attempts": 5,
46
+ }
47
+ },
48
+ "max_attempts": 10,
49
+ "backoff_factor": 3,
50
+ "purge": {"finished": "10 days", "unfinished": "20 days"},
51
+ },
52
+ }
53
+ }
54
+
55
+ # Application definition
27
56
  INSTALLED_APPS = [
28
57
  "django.contrib.admin",
29
58
  "django.contrib.auth",
@@ -32,13 +61,18 @@ INSTALLED_APPS = [
32
61
  "django.contrib.messages",
33
62
  "django.contrib.staticfiles",
34
63
  "sandwitches",
64
+ "django_tasks",
65
+ "django_tasks.backends.database",
35
66
  "debug_toolbar",
67
+ "imagekit",
36
68
  "simple_history",
37
69
  ]
38
70
 
39
71
  MIDDLEWARE = [
40
72
  "django.middleware.security.SecurityMiddleware",
73
+ "whitenoise.middleware.WhiteNoiseMiddleware",
41
74
  "django.contrib.sessions.middleware.SessionMiddleware",
75
+ "django.middleware.locale.LocaleMiddleware",
42
76
  "django.middleware.common.CommonMiddleware",
43
77
  "django.middleware.csrf.CsrfViewMiddleware",
44
78
  "django.contrib.auth.middleware.AuthenticationMiddleware",
@@ -61,6 +95,7 @@ TEMPLATES = [
61
95
  "django.contrib.auth.context_processors.auth",
62
96
  "django.contrib.messages.context_processors.messages",
63
97
  "django.template.context_processors.csrf",
98
+ "django.template.context_processors.i18n",
64
99
  ],
65
100
  },
66
101
  },
@@ -68,17 +103,30 @@ TEMPLATES = [
68
103
 
69
104
  WSGI_APPLICATION = "sandwitches.wsgi.application"
70
105
 
71
-
72
- # Database
73
- # https://docs.djangoproject.com/en/5.2/ref/settings/#databases
74
-
75
106
  DATABASES = {
76
107
  "default": {
77
108
  "ENGINE": "django.db.backends.sqlite3",
78
- "NAME": Path("/config/db.sqlite3"),
109
+ "NAME": DATABASE_FILE,
79
110
  }
80
111
  }
81
112
 
113
+ LOGGING = {
114
+ "version": 1,
115
+ "disable_existing_loggers": False,
116
+ "handlers": {
117
+ "console": {
118
+ "class": "logging.StreamHandler",
119
+ },
120
+ },
121
+ "loggers": {
122
+ "django": {
123
+ "handlers": ["console"],
124
+ "level": os.getenv("LOG_LEVEL", "INFO"),
125
+ "propagate": False,
126
+ },
127
+ },
128
+ }
129
+
82
130
 
83
131
  # Password validation
84
132
  # https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
@@ -105,16 +153,20 @@ MEDIA_ROOT = Path("/config/media")
105
153
 
106
154
  # Static (for CSS etc)
107
155
  STATIC_URL = "/static/"
108
- STATIC_ROOT = Path("/config/staticfiles")
109
-
110
- # Internationalization
111
- # https://docs.djangoproject.com/en/5.2/topics/i18n/
156
+ STATIC_ROOT = Path("/tmp/staticfiles")
157
+ STATIC_URL = "static/"
112
158
 
113
- LANGUAGE_CODE = "en-us"
159
+ STATICFILES_DIRS = [BASE_DIR / "static", MEDIA_ROOT]
114
160
 
161
+ LANGUAGE_CODE = "en"
115
162
  TIME_ZONE = "UTC"
116
-
117
163
  USE_I18N = True
164
+ LANGUAGES = [
165
+ ("en", "English"),
166
+ ("nl", "Nederlands"),
167
+ ]
168
+
169
+ LOCALE_PATHS = [BASE_DIR / "locale"]
118
170
 
119
171
  USE_TZ = True
120
172
 
@@ -122,11 +174,32 @@ INTERNAL_IPS = [
122
174
  "127.0.0.1",
123
175
  ]
124
176
 
177
+ STORAGES = {
178
+ "default": {
179
+ "BACKEND": "django.core.files.storage.FileSystemStorage",
180
+ },
181
+ "staticfiles": {
182
+ "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
183
+ },
184
+ }
125
185
 
126
- # Static files (CSS, JavaScript, Images)
127
- # https://docs.djangoproject.com/en/5.2/howto/static-files/
128
-
129
- STATIC_URL = "static/"
186
+ EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
187
+ EMAIL_USE_TLS = os.environ.get("SMTP_USE_TLS")
188
+ EMAIL_HOST = os.environ.get("SMTP_HOST")
189
+ EMAIL_HOST_USER = os.environ.get("SMTP_USER")
190
+ EMAIL_HOST_PASSWORD = os.environ.get("SMTP_PASSWORD")
191
+ EMAIL_PORT = os.environ.get("SMTP_PORT")
192
+ EMAIL_FROM_ADDRESS = os.environ.get("SMTP_FROM_EMAIL")
193
+ SEND_EMAIL = all(
194
+ v is not None
195
+ for v in [
196
+ EMAIL_HOST,
197
+ EMAIL_HOST_USER,
198
+ EMAIL_HOST_PASSWORD,
199
+ EMAIL_PORT,
200
+ EMAIL_FROM_ADDRESS,
201
+ ]
202
+ )
130
203
 
131
204
  # Default primary key field type
132
205
  # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
sandwitches/storage.py CHANGED
@@ -2,6 +2,9 @@ import hashlib
2
2
  import os
3
3
  from django.core.files.base import ContentFile
4
4
  from django.core.files.storage import FileSystemStorage
5
+ from pathlib import Path
6
+ from django.conf import settings
7
+ import logging
5
8
 
6
9
 
7
10
  class HashedFilenameStorage(FileSystemStorage):
@@ -11,19 +14,69 @@ class HashedFilenameStorage(FileSystemStorage):
11
14
  """
12
15
 
13
16
  def _save(self, name, content):
14
- # ensure we read bytes
15
17
  try:
16
18
  content.seek(0)
17
19
  except Exception:
18
20
  pass
19
21
 
20
22
  data = content.read()
21
- # compute hash (use first 32 hex chars to keep names shorter)
22
23
  h = hashlib.sha256(data).hexdigest()[:32]
23
24
  ext = os.path.splitext(name)[1].lower() or ""
24
- # store all recipe images under recipes/
25
25
  name = f"recipes/{h}{ext}"
26
26
 
27
- # wrap bytes into a ContentFile so Django storage works consistently
28
27
  content = ContentFile(data)
29
28
  return super()._save(name, content)
29
+
30
+
31
+ def is_database_readable(path=None) -> bool:
32
+ """
33
+ Check whether the database file exists and is readable.
34
+
35
+ If `path` is None, uses `django.conf.settings.DATABASE_FILE` by default.
36
+
37
+ Returns:
38
+ bool: True if the file exists and is readable by the current process, False otherwise.
39
+ """
40
+
41
+ if path is None:
42
+ path = getattr(settings, "DATABASE_FILE", None)
43
+
44
+ if not path:
45
+ return False
46
+
47
+ p = Path(path)
48
+ logging.debug(f"Checking database file readability at: {p}")
49
+ readable = p.is_file() and os.access(p, os.R_OK)
50
+ if not readable:
51
+ logging.error(f"Database file at {p} is not readable or does not exist.")
52
+ return False
53
+ else:
54
+ logging.debug(f"Database file at {p} is readable.")
55
+ return readable
56
+
57
+
58
+ def is_database_writable(path=None) -> bool:
59
+ """
60
+ Check whether the database file exists and is writable.
61
+
62
+ If `path` is None, uses `django.conf.settings.DATABASE_FILE` by default.
63
+
64
+ Returns:
65
+ bool: True if the file exists and is writable by the current process, False otherwise.
66
+ """
67
+
68
+ if path is None:
69
+ path = getattr(settings, "DATABASE_FILE", None)
70
+
71
+ if not path:
72
+ return False
73
+
74
+ p = Path(path)
75
+ logging.debug(f"Checking database file writability at: {p}")
76
+ writable = p.is_file() and os.access(p, os.W_OK)
77
+ if not writable:
78
+ logging.error(f"Database file at {p} is not writable or does not exist.")
79
+ return False
80
+ else:
81
+ logging.debug(f"Database file at {p} is writable.")
82
+ return writable
sandwitches/tasks.py ADDED
@@ -0,0 +1,99 @@
1
+ # from gunicorn.http.wsgi import log
2
+ import logging
3
+
4
+ # from django.core.mail import send_mail
5
+ from django_tasks import task
6
+ from django.contrib.auth import get_user_model
7
+
8
+ from django.core.mail import EmailMultiAlternatives
9
+ from django.conf import settings
10
+
11
+
12
+ import textwrap
13
+
14
+
15
+ @task(takes_context=True, priority=2, queue_name="emails")
16
+ def email_users(context, recipe_id):
17
+ logging.debug(
18
+ f"Attempt {context.attempt} to send users an email. Task result id: {context.task_result.id}."
19
+ )
20
+
21
+ User = get_user_model()
22
+ emails = list(
23
+ User.objects.exclude(email__isnull=True)
24
+ .exclude(email="")
25
+ .values_list("email", flat=True)
26
+ )
27
+
28
+ if not emails:
29
+ logging.warning("No users with valid emails found.")
30
+ return 0
31
+
32
+ send_emails(recipe_id, emails)
33
+
34
+ return True
35
+
36
+
37
+ def send_emails(recipe_id, emails):
38
+ from .models import Recipe
39
+
40
+ logging.debug(f"Preparing to send email to: {emails}")
41
+ recipe = Recipe.objects.get(pk=recipe_id) # ty:ignore[unresolved-attribute]
42
+ from_email = getattr(settings, "EMAIL_FROM_ADDRESS")
43
+
44
+ recipe_slug = recipe.get_absolute_url()
45
+ base_url = (
46
+ settings.CSRF_TRUSTED_ORIGINS[0]
47
+ if settings.CSRF_TRUSTED_ORIGINS
48
+ else "http://localhost"
49
+ ).rstrip("/")
50
+
51
+ raw_message = f"""
52
+ Hungry? We just added <strong>{recipe.title}</strong> to our collection.
53
+
54
+ It's a delicious recipe that you won't want to miss!
55
+ {recipe.description}
56
+
57
+ Check out the full recipe, ingredients, and steps here:
58
+ {base_url}{recipe_slug}
59
+
60
+ Happy Cooking!
61
+
62
+ The Sandwitches Team
63
+ """
64
+ wrapped_message = textwrap.fill(textwrap.dedent(raw_message), width=70)
65
+
66
+ html_content = f"""
67
+ <div style="font-family: 'Helvetica', sans-serif; max-width: 600px; margin: auto; border: 1px solid #eee; padding: 20px;">
68
+ <h2 style="color: #d35400; text-align: center;">New Recipe: {recipe.title} by {recipe.uploaded_by}</h2>
69
+ <div style="text-align: center; margin: 20px 0;">
70
+ <img src="{base_url}{recipe.image.url}" alt="{recipe.title}" style="width: 100%; border-radius: 8px;">
71
+ </div>
72
+ <p style="font-size: 16px; line-height: 1.5; color: #333;">
73
+ Hungry? We just added <strong>{recipe.title}</strong> to our collection.
74
+ <br>
75
+ It's a delicious recipe that you won't want to miss!
76
+ <br>
77
+ {recipe.description}
78
+ <br>
79
+ Check out the full recipe, ingredients, and steps here:
80
+ Click the button below to see how to make it!
81
+ <br>
82
+ Happy Cooking!
83
+ <br>
84
+ The Sandwitches Team
85
+ </p>
86
+ <div style="text-align: center; margin-top: 30px;">
87
+ <a href="{base_url}{recipe_slug}" style="background-color: #e67e22; color: white; padding: 12px 25px; text-decoration: none; border-radius: 5px; font-weight: bold;">VIEW RECIPE</a>
88
+ </div>
89
+ </div>
90
+ """
91
+
92
+ msg = EmailMultiAlternatives(
93
+ subject=f"Sandwitches - New Recipe: {recipe.title} by {recipe.uploaded_by}",
94
+ body=wrapped_message,
95
+ from_email=from_email,
96
+ bbc=emails,
97
+ )
98
+ msg.attach_alternative(html_content, "text/html")
99
+ msg.send()
@@ -1,5 +1,6 @@
1
+ {% load i18n %}
1
2
  <!DOCTYPE html>
2
- <html lang="en">
3
+ <html lang="{% get_current_language as LANGUAGE_CODE %}{{ LANGUAGE_CODE }}">
3
4
  <head>
4
5
  <meta charset="utf-8" />
5
6
  <meta name="viewport" content="width=device-width,initial-scale=1.0" />
@@ -1,4 +1,4 @@
1
- {% extends "base.html" %} {% block extra_head %}
1
+ {% extends "base.html" %} {% load i18n static %} {% block extra_head %}
2
2
  <!-- Pico.css (CDN) -->
3
3
  <link
4
4
  rel="stylesheet"
@@ -128,6 +128,7 @@
128
128
  }
129
129
  }
130
130
  </style>
131
+ <link rel="icon" type="image/svg+xml" href="{% static "icons/favicon.svg" %}">
131
132
  {% endblock %} {% block navbar %}
132
133
  <header class="container">
133
134
  <nav>
@@ -168,11 +169,26 @@
168
169
  </li>
169
170
  </ul>
170
171
  <ul>
172
+ <li>
173
+ <form method="post" action="{% url 'set_language' %}">
174
+ {% csrf_token %}
175
+ <select name="language" onchange="this.form.submit()" aria-label="{% trans 'Language' %}">
176
+ {% get_current_language as LANGUAGE_CODE %}
177
+ {% get_available_languages as LANGUAGES %}
178
+ {% for code,name in LANGUAGES %}
179
+ <option value="{{ code }}"{% if code == LANGUAGE_CODE %} selected{% endif %}>{{ name }}</option>
180
+ {% endfor %}
181
+ </select>
182
+ <input type="hidden" name="next" value="{{ request.get_full_path }}" />
183
+ </form>
184
+ </li>
185
+ <li><a href="api/docs">{% trans "Docs" %}</a></li>
171
186
  {% if user.is_authenticated %}
172
- <li><a href="{% url 'admin:index' %}">Admin</a></li>
173
- <li><a href="{% url 'admin:logout' %}">Logout</a></li>
187
+ <li><a href="{% url 'admin:index' %}">{% trans "Admin" %}</a></li>
188
+ <li><a href="{% url 'admin:logout' %}">{% trans "Logout" %}</a></li>
174
189
  {% else %}
175
- <li><a href="{% url 'admin:login' %}">Login</a></li>
190
+ <li><a href="{% url 'admin:login' %}">{% trans "Login" %}</a></li>
191
+ <li><a href="{% url 'signup' %}">{% trans "Sign up" %}</a></li>
176
192
  {% endif %}
177
193
  </ul>
178
194
  </nav>