diff --git a/web/forms.py b/web/forms.py index 96489d524..16af84593 100644 --- a/web/forms.py +++ b/web/forms.py @@ -1690,7 +1690,7 @@ class Meta: attrs={ "accept": "image/png,image/jpeg,image/gif", "required": True, - "help_text": "Upload a meme image (JPG, PNG, or GIF, max 2MB)", + "help_text": "Upload a meme image (JPG, PNG, or GIF, max 1MB)", } ), } @@ -1700,11 +1700,16 @@ def __init__(self, *args, **kwargs): self.fields["subject"].required = False self.fields["subject"].help_text = "Select an existing subject" - # Improve error messages - self.fields["image"].error_messages = { - "required": "Please select an image file.", - "invalid": "Please upload a valid image file.", - } + # When editing an existing meme, the image should not be required. + if self.instance and self.instance.pk and "image" in self.fields: + self.fields["image"].required = False + # Image field is excluded from MemeEditForm; this branch exists only for defensive coverage + + if "image" in self.fields: + self.fields["image"].error_messages = { + "required": "Please select an image file.", + "invalid": "Please upload a valid image file.", + } def clean(self): cleaned_data = super().clean() @@ -1716,6 +1721,20 @@ def clean(self): return cleaned_data + def clean_image(self): + image = self.cleaned_data.get("image") + # if editing and no new file provided, don't validate size/contents + if not image: + return image + + if self.instance and self.instance.pk: + raise ValidationError("Uploaded image cannot be replaced after upload.") + + limit_mb = 1 + if image.size > limit_mb * 1024 * 1024: + raise ValidationError(f"Image file is too large. Size should not exceed {limit_mb} MB.") + return image + def save(self, commit=True): meme = super().save(commit=False) @@ -1734,6 +1753,19 @@ def save(self, commit=True): return meme +# Form used during updates, image may not be changed by users +class MemeEditForm(MemeForm): + """Form used during meme updates; uploaded image is immutable.""" + + class Meta(MemeForm.Meta): + fields = tuple(f for f in MemeForm.Meta.fields if f != "image") + + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + # ensure image field is not present + self.fields.pop("image", None) + + class StudentEnrollmentForm(forms.Form): first_name = forms.CharField( max_length=30, required=True, widget=TailwindInput(attrs={"placeholder": "First Name"}), label="First Name" diff --git a/web/migrations/0026_enhance_meme_model.py b/web/migrations/0026_enhance_meme_model.py index c0f3cfc12..1d2a2185b 100644 --- a/web/migrations/0026_enhance_meme_model.py +++ b/web/migrations/0026_enhance_meme_model.py @@ -24,7 +24,7 @@ class Migration(migrations.Migration): ( "image", models.ImageField( - help_text="Upload a meme image (JPG, PNG, or GIF, max 2MB)", + help_text="Upload a meme image (JPG, PNG, or GIF, max 1MB)", upload_to="memes/", validators=[web.models.validate_image_size, web.models.validate_image_extension], ), diff --git a/web/models.py b/web/models.py index 6b8ea8ef4..ac98a780f 100644 --- a/web/models.py +++ b/web/models.py @@ -1765,7 +1765,7 @@ def save(self, *args, **kwargs): def validate_image_size(image): """Validate that the image file is not too large.""" file_size = image.size - limit_mb = 2 + limit_mb = 1 if file_size > limit_mb * 1024 * 1024: raise ValidationError(f"Image file is too large. Size should not exceed {limit_mb} MB.") @@ -1794,7 +1794,7 @@ class Meme(models.Model): image = models.ImageField( upload_to="memes/", validators=[validate_image_size, validate_image_extension], - help_text=_("Upload a meme image (JPG, PNG, or GIF, max 2MB)"), + help_text=_("Upload a meme image (JPG, PNG, or GIF, max 1MB)"), ) uploader = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, related_name="memes", null=True) created_at = models.DateTimeField(auto_now_add=True) diff --git a/web/templates/add_meme.html b/web/templates/add_meme.html index e7c12a71e..d37b078c1 100644 --- a/web/templates/add_meme.html +++ b/web/templates/add_meme.html @@ -1,26 +1,44 @@ {% extends "base.html" %} {% block title %} - Add Educational Meme + {% if is_edit %} + Edit Educational Meme + {% else %} + Add Educational Meme + {% endif %} {% endblock title %} {% block content %}
-

Add a New Educational Meme

+

+ {% if is_edit %} + Edit + {% else %} + Add a New + {% endif %} + Educational Meme +

{% csrf_token %}
- + - {% if form.title.errors %}

{{ form.title.errors.0 }}

{% endif %} + {% if form.title.errors %}

{{ form.title.errors.0 }}

{% endif %}
- + - {% if form.subject.errors %}

{{ form.subject.errors.0 }}

{% endif %} + {% if form.subject.errors %}

{{ form.subject.errors.0 }}

{% endif %}

{{ form.subject.help_text }}

+ class="block mb-2 font-medium"> + Or create new subject + {% if form.new_subject.field.required %}*{% endif %} + Add a New Educational Meme

If your subject isn't listed above, enter a new one here

- + - {% if form.caption.errors %} -

{{ form.caption.errors.0 }}

- {% endif %} + {% if form.caption.errors %}

{{ form.caption.errors.0 }}

{% endif %}
-
- - {{ form.image }} - {% if form.image.errors %}

{{ form.image.errors.0 }}

{% endif %} -

Recommended: PNG or JPEG, max 2MB

- + {{ is_edit|json_script:"is-edit-data" }} {% endblock content %} diff --git a/web/templates/meme_detail.html b/web/templates/meme_detail.html index ef1726c0c..42265615e 100644 --- a/web/templates/meme_detail.html +++ b/web/templates/meme_detail.html @@ -18,10 +18,28 @@

{{ meme.title }}{{ meme.created_at|date:'F j, Y' }}

- - Back to memes - +
+ + Back to memes + + {% if user.is_authenticated and meme.uploader and user.id == meme.uploader.id %} + + Edit + +
+ {% csrf_token %} + +
+ {% endif %} +
/", views.meme_detail, name="meme_detail"), + path("memes//edit/", views.update_meme, name="meme_update"), + path("memes//delete/", views.delete_meme, name="meme_delete"), path("whiteboard/", views.whiteboard, name="whiteboard"), path("gsoc/", views.gsoc_landing_page, name="gsoc_landing_page"), path("sync_github_milestones/", views.sync_github_milestones, name="sync_github_milestones"), diff --git a/web/views.py b/web/views.py index b4d485749..2fbba48d7 100644 --- a/web/views.py +++ b/web/views.py @@ -4594,6 +4594,13 @@ def graphing_calculator(request): return render(request, "graphing_calculator.html") +def check_meme_owner(meme: Meme, user: User) -> HttpResponseForbidden | None: + """Return HttpResponseForbidden if user is not the meme owner, else None.""" + if not meme.uploader or meme.uploader.id != user.id: + return HttpResponseForbidden("You do not have permission to modify this meme.") + return None + + def meme_list(request): memes = Meme.objects.all().order_by("-created_at") subjects = Subject.objects.filter(memes__isnull=False).distinct() @@ -4629,6 +4636,44 @@ def add_meme(request): return render(request, "add_meme.html", {"form": form, "subjects": subjects}) +@login_required +def update_meme(request: HttpRequest, slug: str) -> HttpResponse: + """Edit meme metadata for the uploader while keeping the image immutable.""" + meme = get_object_or_404(Meme, slug=slug) + forbidden = check_meme_owner(meme, request.user) + if forbidden: + return forbidden + + from .forms import MemeEditForm + + if request.method == "POST": + form = MemeEditForm(request.POST, instance=meme) + if form.is_valid(): + meme_obj = form.save(commit=False) + meme_obj.image = meme.image + meme_obj.save() + messages.success(request, "Meme updated successfully.") + return redirect("meme_detail", slug=meme.slug) + else: + form = MemeEditForm(instance=meme) + + subjects = Subject.objects.all().order_by("name") + return render(request, "add_meme.html", {"form": form, "subjects": subjects, "is_edit": True}) + + +@login_required +@require_POST +def delete_meme(request: HttpRequest, slug: str) -> HttpResponse: + """Delete a meme owned by the current user.""" + meme = get_object_or_404(Meme, slug=slug) + forbidden = check_meme_owner(meme, request.user) + if forbidden: + return forbidden + meme.delete() + messages.success(request, "Meme deleted successfully.") + return redirect("meme_list") + + @login_required def team_goals(request): """List all team goals the user is part of or has created."""