Repository: WeblateOrg/weblate

Issue:
Using ETag + If-None-Match or Last-Modified + If-Modified-Since headers to make use of 304 - Not Modified HTTP status
### Describe the problem

This may already have been implemented in Weblate but I don't see it anywhere in the documentation, nor did I manage to get a 304 response by testing Weblate v4.13.1

Our translation files tend to get quite large and they change reasonably often, so we send a lot of requests to pick up the freshest translation files. In the response we get back I can see that there is the Last-Modified header.
It would be nice if Weblate responded with the 304 HTTP status code if we send the If-Modified-Since header and there aren't any changes, so we don't have to re-download the file again and instead just re-validate our cache.

This could save a lot of unnecessary bandwidth usage in our use-case.



### Describe the solution you'd like

It would be nice if Weblate responded with the 304 HTTP status code if we send the If-Modified-Since header and there aren't any changes, so we don't have to re-download the file again and instead just re-validate our cache.

### Describe alternatives you've considered

_No response_

### Screenshots

_No response_

### Additional context

_No response_


Discussion:
Which endpoints are you interested in for this? For most of the views this doesn't make sense because there are far too many things which can influence rendering, see https://github.com/WeblateOrg/weblate/issues/7716.
Thanks for the prompt reply!

For the actual file API endpoint, not the UI pages, e.g. /api/translations/project-slug/component-slug/language-code/file/
I also noticed that the Last-Modified header is only sent if there were modifications from the Weblate UI.
If I use VCS to make any modifications in the source repo directly, these changes get picked up in the Weblate UI but then the Last-Modified header remains with the timestamp of the last change made directly through the Weblate UI.

It's a pity, as we could have relied on this for our CDN layer - so that at least the end-users could have made use of the 304-Not Modified status (by us having a CDN in front of our Weblate instance) even without Weblate supporting If-Modified-Since
I can see that there are a few already closed issues related to this... so I don't have high hopes :)
[#6548](https://github.com/WeblateOrg/weblate/issues/6548)
[#5726](https://github.com/WeblateOrg/weblate/issues/5726)
[#7098](https://github.com/WeblateOrg/weblate/issues/7098)
This issue has been automatically marked as stale because there wasn’t any recent activity.

It will be closed soon if no further action occurs.

Thank you for your contributions!
I have a similar case to @nszabi and https://github.com/WeblateOrg/weblate/issues/7716 is not answering it. 

Why does the Last-Modified header remain unmodified when changing translations using version control? 
Because we currently track only a single last changed timestamp, and that tracks only changes happening inside Weblate. This timestamp is needed internally by Weblate (for example in https://docs.weblate.org/en/latest/admin/projects.html#age-of-changes-to-commit) and thus needs to be implemented this way.

The solution would be to track these as separate timestamps to cover both use cases.
This issue has been added to the backlog. It is not scheduled on the Weblate roadmap, but it eventually might be implemented.

In case you need this feature soon, please consider helping or push it by [funding the development](https://weblate.org/support/).
Thanks for the clarification. In the meanwhile I just removed the check for the change type when caching it so it will just use whichever is the latest but of course that leads to some non-critical errors in update_stats_parent and a few other background tasks.

I quite like your suggestion and will probably add it as a separate key into Redis and just return that for the Last-Modified header.

If I find some spare time I'll maybe open a PR for it here but please don't hold your breath. :) 

Thanks again, you have a really nice project going on there with Weblate!

Resolving patch:
diff --git a/weblate/utils/views.py b/weblate/utils/views.py
--- a/weblate/utils/views.py
+++ b/weblate/utils/views.py
@@ -7,15 +7,23 @@
 from __future__ import annotations
 
 import os
+import time
 from contextlib import suppress
-from time import mktime
 from typing import Any
 from zipfile import ZipFile
 
 from django.conf import settings
 from django.core.paginator import EmptyPage, Paginator
-from django.http import FileResponse, Http404, HttpResponse, HttpResponseRedirect
+from django.http import (
+    FileResponse,
+    Http404,
+    HttpRequest,
+    HttpResponse,
+    HttpResponseBase,
+    HttpResponseRedirect,
+)
 from django.shortcuts import get_object_or_404
+from django.utils.cache import get_conditional_response
 from django.utils.http import http_date
 from django.utils.translation import activate, gettext, gettext_lazy, pgettext_lazy
 from django.views.decorators.gzip import gzip_page
@@ -26,7 +34,7 @@
 from weblate.trans.models import Category, Component, Project, Translation, Unit
 from weblate.utils import messages
 from weblate.utils.errors import report_error
-from weblate.utils.stats import CategoryLanguage, ProjectLanguage
+from weblate.utils.stats import BaseStats, CategoryLanguage, ProjectLanguage
 from weblate.vcs.git import LocalRepository
 
 
@@ -445,6 +453,22 @@ def zip_download(
     return response
 
 
+def handle_last_modified(
+    request: HttpRequest, stats: BaseStats
+) -> tuple[str, HttpResponseBase | None]:
+    last_modified = stats.last_changed
+    if last_modified:
+        last_modified = int(last_modified.timestamp())
+        # Respond with 302/412 response if needed
+        response = get_conditional_response(request, last_modified=last_modified)
+    else:
+        # Use current timestamp if stats do not have any
+        last_modified = int(time.time())
+        response = None
+
+    return http_date(last_modified), response
+
+
 @gzip_page
 def download_translation_file(
     request,
@@ -452,6 +476,10 @@ def download_translation_file(
     fmt: str | None = None,
     query_string: str | None = None,
 ):
+    last_modified_response, response = handle_last_modified(request, translation.stats)
+    if response is not None:
+        return response
+
     if fmt is not None:
         try:
             exporter_cls = EXPORTERS[fmt]
@@ -504,10 +532,7 @@ def download_translation_file(
         # Fill in response headers
         response["Content-Disposition"] = f"attachment; filename={filename}"
 
-    if translation.stats.last_changed:
-        response["Last-Modified"] = http_date(
-            mktime(translation.stats.last_changed.timetuple())
-        )
+    response["Last-Modified"] = last_modified_response
 
     return response
 


Test patch:
diff --git a/weblate/api/tests.py b/weblate/api/tests.py
--- a/weblate/api/tests.py
+++ b/weblate/api/tests.py
@@ -96,12 +96,13 @@ def do_request(
         superuser: bool = False,
         method="get",
         request=None,
+        headers=None,
         skip=(),
         format: str = "multipart",
     ):
         self.authenticate(superuser)
         url = name if name.startswith(("http:", "/")) else reverse(name, kwargs=kwargs)
-        response = getattr(self.client, method)(url, request, format)
+        response = getattr(self.client, method)(url, request, format, headers=headers)
         content = response.content if hasattr(response, "content") else "<stream>"
 
         self.assertEqual(
@@ -2291,6 +2292,21 @@ def test_download(self):
         )
         self.assertContains(response, "Project-Id-Version: Weblate Hello World 2016")
 
+    def test_download_modified(self):
+        response = self.do_request(
+            "api:translation-file",
+            kwargs=self.translation_kwargs,
+            headers={"If-Modified-Since": "Wed, 21 Oct 2015 07:28:00 GMT"},
+            code=200,
+        )
+        self.assertContains(response, "Project-Id-Version: Weblate Hello World 2016")
+        self.do_request(
+            "api:translation-file",
+            kwargs=self.translation_kwargs,
+            headers={"If-Modified-Since": response["Last-Modified"]},
+            code=304,
+        )
+
     def test_download_args(self):
         response = self.do_request(
             "api:translation-file",