Skip to content

Commit 43f821a

Browse files
committed
projects: add periodic tasks for cache clear
1 parent 4263492 commit 43f821a

File tree

12 files changed

+419
-14
lines changed

12 files changed

+419
-14
lines changed

changelog/2223.md

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
### Added
2+
3+
- logger in apps init file

changelog/7275.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
### Added
22

33
- enables caching for api endpoints `api/{plans,extprojects,projects}/`
4-
- caches are expired by signals and by timeouts, for details, see `docs/api_caching.md`
4+
- caches are expired by signals and by periodic tasks, for details, see `docs/api_caching.md`
55

docs/api_caching.md

+28-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ These paths correspond to the following api views:
1212
- `plans/api.py::PlansListViewSet`
1313
- `extprojects/api.py::ExternalProjectListViewSet`
1414

15-
we decided to start caching the endpoints with a redis backend.
15+
we decided to start caching the endpoints with redis.
1616

1717
## Developer Notes
1818

@@ -34,4 +34,31 @@ The cache keys for projects are constructed by the view namespace and their stat
3434
- `extprojects`
3535
- `plans`
3636

37+
## Celery tasks
38+
39+
Two periodic tasks check for projects that will become either active or past in the next 10 minutes.
40+
- schedule_reset_cache_for_projects_becoming_active()
41+
- schedule_reset_cache_for_projects_becoming_past()
42+
43+
In case of projects becoming active the cache is cleared for:
44+
- `projects`
45+
- `projects_activeParticipation`
46+
- `projects_futureParticipation`
47+
- `privateprojects`
48+
- `extprojects`
49+
50+
in case of projects becoming past the cache is cleared for:
51+
- `projects`
52+
- `projects_activeParticipation`
53+
- `projects_pastParticipation`
54+
- `privateprojects`
55+
- `extprojects`
56+
3757
In production, we use django's built-in [Redis](https://docs.djangoproject.com/en/4.2/topics/cache/#redis) as cache backend (see `settings/production.py::CACHES`). For development and testing the cache backend is the default, that is [local memory](https://docs.djangoproject.com/en/4.2/topics/cache/#local-memory-caching). If you want to enable redis cache for local development, then copy the production settings to your `settings/local.py`.
58+
59+
files:
60+
- `./meinberlin/apps/plans/api.py`
61+
- `./meinberlin/apps/extprojects/api.py`
62+
- `./meinberlin/apps/projects/api.py`
63+
- `./meinberlin/apps/projects/tasks.py`
64+
- `./meinberlin/config/settings/production.py`

meinberlin/apps/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import logging
2+
3+
logger = logging.getLogger(__name__)

meinberlin/apps/bplan/tasks.py

+1-3
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
import json
2-
import logging
32
import urllib
43

54
from celery import shared_task
65

76
from adhocracy4.administrative_districts.models import AdministrativeDistrict
87
from adhocracy4.projects.models import Topic
8+
from meinberlin.apps import logger
99
from meinberlin.apps.bplan.models import Bplan
1010

11-
logger = logging.getLogger(__name__)
12-
1311

1412
def get_features_from_bplan_api(endpoint):
1513
url = "https://bplan-prod.liqd.net/api/" + endpoint

meinberlin/apps/projects/signals.py

+8
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
from adhocracy4.dashboard import signals as a4dashboard_signals
77
from adhocracy4.projects.models import Project
8+
from meinberlin.apps.projects.tasks import get_next_project_ends
9+
from meinberlin.apps.projects.tasks import get_next_projects_start
810

911

1012
@receiver(a4dashboard_signals.project_created)
@@ -39,3 +41,9 @@ def post_save_delete(sender, instance, update_fields=None, **kwargs):
3941
"extprojects",
4042
]
4143
)
44+
45+
# add datetime for the next projects that will be published in the next 10min
46+
cache.set("next_projects_start", get_next_projects_start())
47+
48+
# add datetime for the earliest next project that ends and should be unpublished
49+
cache.set("next_project_ends", get_next_project_ends())

meinberlin/apps/projects/tasks.py

+169
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
from datetime import datetime
2+
from datetime import timedelta
3+
from datetime import timezone
4+
5+
from celery import shared_task
6+
from dateutil import parser
7+
from django.core.cache import cache
8+
9+
from adhocracy4.phases.models import Phase
10+
from meinberlin.apps import logger
11+
12+
13+
def get_next_projects_start() -> list:
14+
"""
15+
Helper function to query the db and retreive the
16+
phases for projects that will start in the next 10min.
17+
"""
18+
now = datetime.now(tz=timezone.utc) # tz is UTC
19+
phases = (
20+
Phase.objects.filter(
21+
start_date__isnull=False,
22+
start_date__gt=now,
23+
start_date__lt=now + timedelta(minutes=10),
24+
)
25+
.order_by("start_date")
26+
.all()
27+
)
28+
list_format_phases = []
29+
if phases:
30+
for phase in phases:
31+
str_phase = phase.start_date.astimezone(timezone.utc).strftime(
32+
"%Y-%m-%d, %H:%M:%S %Z"
33+
)
34+
list_format_phases.append(str_phase)
35+
return list_format_phases
36+
37+
38+
def get_next_project_ends() -> str:
39+
"""
40+
Helper function to query the db and
41+
retreive the erliest phase that will end.
42+
"""
43+
now = datetime.now(tz=timezone.utc) # tz is UTC
44+
phase = (
45+
Phase.objects.filter(end_date__isnull=False, end_date__gt=now)
46+
.order_by("end_date")
47+
.first()
48+
)
49+
if not phase:
50+
return ""
51+
str_format_phase = phase.end_date.astimezone(timezone.utc).strftime(
52+
"%Y-%m-%d, %H:%M:%S %Z"
53+
)
54+
return str_format_phase
55+
56+
57+
@shared_task(name="schedule_reset_cache_for_projects_becoming_active")
58+
def schedule_reset_cache_for_projects_becoming_active():
59+
"""The task is set via celery beat every 10 minutes in
60+
settings/production.txt.
61+
62+
Returns:
63+
A string message with a list of the projects becoming active
64+
in the next 10 minutes and when the cache will be cleared.
65+
"""
66+
67+
# check if redis has a next_project_starts key
68+
list_next_projects_start = cache.get("next_projects_start")
69+
if list_next_projects_start is None or list_next_projects_start == []:
70+
list_next_projects_start = get_next_projects_start()
71+
72+
msg = "Future projects becoming active in next 10mins will be removed from cache: "
73+
74+
if list_next_projects_start: # if not an empty list
75+
# set the redis key: value
76+
cache.set("next_projects_start", list_next_projects_start)
77+
78+
for str_project_starts in list_next_projects_start:
79+
# convert to datetime obj
80+
date_project_starts = parser.parse(str_project_starts)
81+
82+
# compare now with next start date
83+
now = datetime.now(tz=timezone.utc)
84+
remain_time = date_project_starts - now
85+
86+
if remain_time <= timedelta(minutes=10):
87+
# schedule cache clear for the remaining time btwn now and next start
88+
reset_cache_for_projects.apply_async(
89+
[True, False], countdown=remain_time
90+
)
91+
msg += f"""
92+
{date_project_starts} in {remain_time} minutes
93+
"""
94+
else:
95+
msg += "None"
96+
return logger.info(msg)
97+
98+
99+
@shared_task(name="schedule_reset_cache_for_projects_becoming_past")
100+
def schedule_reset_cache_for_projects_becoming_past():
101+
"""The task is set via celery beat every 10 minutes in
102+
settings/production.txt.
103+
104+
Returns:
105+
A string message if the next project ends
106+
date will be removed from the cache.
107+
"""
108+
109+
# check if redis has a next_project_ends key
110+
str_project_ends = cache.get("next_project_ends")
111+
112+
if not str_project_ends:
113+
str_project_ends = get_next_project_ends()
114+
if not str_project_ends:
115+
raise Exception("At least one phase with dates is required")
116+
# set the redis key: value
117+
cache.set("next_project_ends", str_project_ends)
118+
119+
msg = "Active projects will be removed from cache: "
120+
121+
# compare now with next end date
122+
date_project_ends = parser.parse(str_project_ends)
123+
now = datetime.now(tz=timezone.utc)
124+
remain_time = now - date_project_ends
125+
if remain_time <= timedelta(minutes=10):
126+
# schedule cache clear for the remaining time btwn now and next end
127+
reset_cache_for_projects.apply_async([False, True], countdown=remain_time)
128+
msg = f"""
129+
{date_project_ends} in {remain_time} minutes
130+
"""
131+
else:
132+
msg += "None"
133+
return logger.info(msg)
134+
135+
136+
@shared_task
137+
def reset_cache_for_projects(starts, ends):
138+
msg = "Clear cache "
139+
if starts:
140+
# remove redis key next_project_start
141+
cache.delete("next_project_starts")
142+
cache.delete_many(
143+
[
144+
"projects_activeParticipation",
145+
"projects_futureParticipation",
146+
"private_projects",
147+
"extprojects",
148+
]
149+
)
150+
if cache.get("projects_*") or cache.get("extprojects"):
151+
msg += "failed for future projects becoming active"
152+
else:
153+
msg += "succeeded for future projects becoming active"
154+
if ends:
155+
# remove redis key next_project_ends
156+
cache.delete("next_project_ends")
157+
cache.delete_many(
158+
[
159+
"projects_activeParticipation",
160+
"projects_pastParticipation",
161+
"private_projects",
162+
"extprojects",
163+
]
164+
)
165+
if cache.get("projects_*") or cache.get("extprojects"):
166+
msg += "failed for active projects becoming past"
167+
else:
168+
msg += "succeeded for active projects becoming past"
169+
return logger.info(msg)

meinberlin/config/settings/dev.py

+7
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99
# SECURITY WARNING: keep the secret key used in production secret!
1010
SECRET_KEY = "qid$h1o8&wh#p(j)lifis*5-rf@lbiy8%^3l4x%@b$z(tli@ab"
1111

12+
CACHES = {
13+
"default": {
14+
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
15+
"LOCATION": "unique-snowflake",
16+
}
17+
}
18+
1219
CELERY_TASK_ALWAYS_EAGER = True
1320

1421
try:

meinberlin/config/settings/production.py

+14-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
CACHES = {
66
"default": {
77
"BACKEND": "django.core.cache.backends.redis.RedisCache",
8-
"LOCATION": "redis://127.0.0.1:6379",
8+
"LOCATION": "redis://127.0.0.1:6379/1", # defaut is 0 and is taken by celery for backend results
99
"TIMEOUT": 60,
1010
}
1111
}
@@ -39,3 +39,16 @@
3939
CKEDITOR_CONFIGS["video-editor"]["embed_provider"] = CKEDITOR_URL
4040
except NameError:
4141
pass
42+
43+
CELERY_BEAT_SCHEDULE = {
44+
"update-future-projects-every-10-mim": {
45+
"task": "schedule_reset_cache_for_projects_becoming_active",
46+
"schedule": crontab(minute="*/10"),
47+
"args": (),
48+
},
49+
"update-past-projects-every-10-mim": {
50+
"task": "schedule_reset_cache_for_projects_becoming_past",
51+
"schedule": crontab(minute="*/10"),
52+
"args": (),
53+
},
54+
}

meinberlin/config/settings/test.py

-7
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,6 @@
88

99
ACCOUNT_EMAIL_VERIFICATION = "optional"
1010

11-
CACHES = {
12-
"default": {
13-
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
14-
"LOCATION": "unique-snowflake",
15-
}
16-
}
17-
1811
CAPTCHA_TEST_ACCEPTED_ANSWER = "testpass"
1912
CAPTCHA_URL = "https://captcheck.netsyms.com/api.php"
2013

tests/conftest.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def signup_url():
9494
return reverse("account_signup")
9595

9696

97-
@pytest.fixture(scope="module", autouse=True)
97+
@pytest.fixture(scope="function", autouse=True)
9898
def cache_clear():
9999
yield cache
100100
cache.clear()

0 commit comments

Comments
 (0)