|
3 | 3 |
|
4 | 4 | import structlog
|
5 | 5 | from celery.worker.request import Request
|
6 |
| -from django.db.models import Q |
| 6 | +from django.conf import settings |
| 7 | +from django.contrib.auth.models import User |
| 8 | +from django.db.models import Q, Sum |
7 | 9 | from django.utils import timezone
|
8 | 10 | from django.utils.translation import gettext_lazy as _
|
| 11 | +from djstripe.enums import SubscriptionStatus |
| 12 | +from messages_extends.constants import WARNING_PERSISTENT |
9 | 13 |
|
10 | 14 | from readthedocs.builds.constants import (
|
11 | 15 | BUILD_FINAL_STATES,
|
|
14 | 18 | )
|
15 | 19 | from readthedocs.builds.models import Build
|
16 | 20 | from readthedocs.builds.tasks import send_build_status
|
| 21 | +from readthedocs.core.permissions import AdminPermission |
17 | 22 | from readthedocs.core.utils.filesystem import safe_rmtree
|
| 23 | +from readthedocs.notifications import Notification, SiteNotification |
| 24 | +from readthedocs.notifications.backends import EmailBackend |
| 25 | +from readthedocs.notifications.constants import REQUIREMENT |
| 26 | +from readthedocs.projects.models import Project |
18 | 27 | from readthedocs.storage import build_media_storage
|
19 | 28 | from readthedocs.worker import app
|
20 | 29 |
|
@@ -154,6 +163,158 @@ def send_external_build_status(version_type, build_pk, commit, status):
|
154 | 163 | send_build_status.delay(build_pk, commit, status)
|
155 | 164 |
|
156 | 165 |
|
| 166 | +class DeprecatedConfigFileSiteNotification(SiteNotification): |
| 167 | + |
| 168 | + # TODO: mention all the project slugs here |
| 169 | + # Maybe trim them to up to 5 projects to avoid sending a huge blob of text |
| 170 | + failure_message = _( |
| 171 | + 'Your project(s) "{{ project_slugs }}" don\'t have a configuration file. ' |
| 172 | + "Configuration files will <strong>soon be required</strong> by projects, " |
| 173 | + "and will no longer be optional. " |
| 174 | + '<a href="https://blog.readthedocs.com/migrate-configuration-v2/">Read our blog post to create one</a> ' # noqa |
| 175 | + "and ensure your project continues building successfully." |
| 176 | + ) |
| 177 | + failure_level = WARNING_PERSISTENT |
| 178 | + |
| 179 | + |
| 180 | +class DeprecatedConfigFileEmailNotification(Notification): |
| 181 | + |
| 182 | + app_templates = "projects" |
| 183 | + name = "deprecated_config_file_used" |
| 184 | + context_object_name = "project" |
| 185 | + subject = "[Action required] Add a configuration file to your project to prevent build failure" |
| 186 | + level = REQUIREMENT |
| 187 | + |
| 188 | + def send(self): |
| 189 | + """Method overwritten to remove on-site backend.""" |
| 190 | + backend = EmailBackend(self.request) |
| 191 | + backend.send(self) |
| 192 | + |
| 193 | + |
| 194 | +@app.task(queue="web") |
| 195 | +def deprecated_config_file_used_notification(): |
| 196 | + """ |
| 197 | + Create a notification about not using a config file for all the maintainers of the project. |
| 198 | +
|
| 199 | + This is a scheduled task to be executed on the webs. |
| 200 | + Note the code uses `.iterator` and `.only` to avoid killing the db with this query. |
| 201 | + Besdies, it excludes projects with enough spam score to be skipped. |
| 202 | + """ |
| 203 | + # Skip projects with a spam score bigger than this value. |
| 204 | + # Currently, this gives us ~250k in total (from ~550k we have in our database) |
| 205 | + spam_score = 300 |
| 206 | + |
| 207 | + projects = set() |
| 208 | + start_datetime = datetime.datetime.now() |
| 209 | + queryset = Project.objects.exclude(users__profile__banned=True) |
| 210 | + if settings.ALLOW_PRIVATE_REPOS: |
| 211 | + # Only send emails to active customers |
| 212 | + queryset = queryset.filter( |
| 213 | + organizations__stripe_subscription__status=SubscriptionStatus.active |
| 214 | + ) |
| 215 | + else: |
| 216 | + # Take into account spam score on community |
| 217 | + queryset = queryset.annotate(spam_score=Sum("spam_rules__value")).filter( |
| 218 | + Q(spam_score__lt=spam_score) | Q(is_spam=False) |
| 219 | + ) |
| 220 | + queryset = queryset.only("slug", "default_version").order_by("id") |
| 221 | + n_projects = queryset.count() |
| 222 | + |
| 223 | + for i, project in enumerate(queryset.iterator()): |
| 224 | + if i % 500 == 0: |
| 225 | + log.info( |
| 226 | + "Finding projects without a configuration file.", |
| 227 | + progress=f"{i}/{n_projects}", |
| 228 | + current_project_pk=project.pk, |
| 229 | + current_project_slug=project.slug, |
| 230 | + projects_found=len(projects), |
| 231 | + time_elapsed=(datetime.datetime.now() - start_datetime).seconds, |
| 232 | + ) |
| 233 | + |
| 234 | + # Only check for the default version because if the project is using tags |
| 235 | + # they won't be able to update those and we will send them emails forever. |
| 236 | + # We can update this query if we consider later. |
| 237 | + version = ( |
| 238 | + project.versions.filter(slug=project.default_version).only("id").first() |
| 239 | + ) |
| 240 | + if version: |
| 241 | + build = ( |
| 242 | + version.builds.filter(success=True) |
| 243 | + .only("_config") |
| 244 | + .order_by("-date") |
| 245 | + .first() |
| 246 | + ) |
| 247 | + if build and build.deprecated_config_used(): |
| 248 | + projects.add(project.slug) |
| 249 | + |
| 250 | + # Store all the users we want to contact |
| 251 | + users = set() |
| 252 | + |
| 253 | + n_projects = len(projects) |
| 254 | + queryset = Project.objects.filter(slug__in=projects).order_by("id") |
| 255 | + for i, project in enumerate(queryset.iterator()): |
| 256 | + if i % 500 == 0: |
| 257 | + log.info( |
| 258 | + "Querying all the users we want to contact.", |
| 259 | + progress=f"{i}/{n_projects}", |
| 260 | + current_project_pk=project.pk, |
| 261 | + current_project_slug=project.slug, |
| 262 | + users_found=len(users), |
| 263 | + time_elapsed=(datetime.datetime.now() - start_datetime).seconds, |
| 264 | + ) |
| 265 | + |
| 266 | + users.update(AdminPermission.owners(project).values_list("username", flat=True)) |
| 267 | + |
| 268 | + # Only send 1 email per user, |
| 269 | + # even if that user has multiple projects without a configuration file. |
| 270 | + # The notification will mention all the projects. |
| 271 | + queryset = User.objects.filter(username__in=users, profile__banned=False).order_by( |
| 272 | + "id" |
| 273 | + ) |
| 274 | + n_users = queryset.count() |
| 275 | + for i, user in enumerate(queryset.iterator()): |
| 276 | + if i % 500 == 0: |
| 277 | + log.info( |
| 278 | + "Sending deprecated config file notification to users.", |
| 279 | + progress=f"{i}/{n_users}", |
| 280 | + current_user_pk=user.pk, |
| 281 | + current_user_username=user.username, |
| 282 | + time_elapsed=(datetime.datetime.now() - start_datetime).seconds, |
| 283 | + ) |
| 284 | + |
| 285 | + # All the projects for this user that don't have a configuration file |
| 286 | + user_projects = ( |
| 287 | + AdminPermission.projects(user, admin=True) |
| 288 | + .filter(slug__in=projects) |
| 289 | + .only("slug") |
| 290 | + ) |
| 291 | + |
| 292 | + user_project_slugs = ", ".join([p.slug for p in user_projects[:5]]) |
| 293 | + if user_projects.count() > 5: |
| 294 | + user_project_slugs += " and others..." |
| 295 | + |
| 296 | + n_site = DeprecatedConfigFileSiteNotification( |
| 297 | + user=user, |
| 298 | + context_object=user_projects, |
| 299 | + extra_context={"project_slugs": user_project_slugs}, |
| 300 | + success=False, |
| 301 | + ) |
| 302 | + n_site.send() |
| 303 | + |
| 304 | + # TODO: uncomment this code when we are ready to send email notifications |
| 305 | + # n_email = DeprecatedConfigFileEmailNotification( |
| 306 | + # user=user, |
| 307 | + # context_object=user_projects, |
| 308 | + # extra_context={"project_slugs": user_project_slugs}, |
| 309 | + # ) |
| 310 | + # n_email.send() |
| 311 | + |
| 312 | + log.info( |
| 313 | + "Finish sending deprecated config file notifications.", |
| 314 | + time_elapsed=(datetime.datetime.now() - start_datetime).seconds, |
| 315 | + ) |
| 316 | + |
| 317 | + |
157 | 318 | class BuildRequest(Request):
|
158 | 319 |
|
159 | 320 | def on_timeout(self, soft, timeout):
|
|
0 commit comments