diff --git a/readthedocs/projects/admin.py b/readthedocs/projects/admin.py index 7f6da9d88fe..16a48946016 100644 --- a/readthedocs/projects/admin.py +++ b/readthedocs/projects/admin.py @@ -2,6 +2,8 @@ """Django administration interface for `projects.models`.""" +import os + from django.contrib import admin, messages from django.contrib.admin.actions import delete_selected from django.utils.translation import ugettext_lazy as _ @@ -9,7 +11,7 @@ from readthedocs.builds.models import Version from readthedocs.core.models import UserProfile -from readthedocs.core.utils import broadcast, trigger_build +from readthedocs.core.utils import broadcast, trigger_build, send_email from readthedocs.notifications.views import SendNotificationView from readthedocs.redirects.models import Redirect from readthedocs.search.utils import _indexing_helper @@ -29,9 +31,10 @@ from .notifications import ( DeprecatedBuildWebhookNotification, DeprecatedGitHubWebhookNotification, + AbandonedProjectNotification, ResourceUsageNotification, ) -from .tasks import remove_dirs +from .tasks import remove_dirs, rename_project_dir class ProjectSendNotificationView(SendNotificationView): @@ -149,6 +152,8 @@ class ProjectAdmin(GuardedModelAdmin): actions = [ 'send_owner_email', 'ban_owner', + 'mark_as_abandoned', + 'request_namespace', 'build_default_version', 'reindex_active_versions', 'wipe_all_versions', @@ -215,6 +220,71 @@ def delete_selected_and_artifacts(self, request, queryset): ) return delete_selected(self, request, queryset) + def mark_as_abandoned(self, request, queryset): + """ + Marks selected projects as abandoned. + + It performs the following sub-tasks: + * Adds '-abandoned' to the slug. + * Updates the project's directory name to + match the new slug. + * Sets Project.is_abandoned=True. + * Adds a persistent error notification for the + user notifying the changes. + * Notify user via email. + """ + qs_iterator = queryset.iterator() + for project in qs_iterator: + new_slug = '{}-abandoned'.format(project.slug) + old_doc_path = project.doc_path + new_doc_path = os.path.join( + os.path.dirname(project.doc_path), + new_slug + ) + broadcast( + type='web', task=rename_project_dir, + args=[old_doc_path, new_doc_path] + ) + project.slug = new_slug + project.is_abandoned = True + project.save() + + # Sending notification emails + users = project.users.get_queryset() + for user in users: + notification = AbandonedProjectNotification( + user=user, + success=False, + extra_context={'project': project} + ) + notification.send() + + self.message_user( + request, + '{} is marked as abandoned.'.format(project.name), + level=messages.SUCCESS + ) + + mark_as_abandoned.short_description = 'Mark as abandoned' + + def request_namespace(self, request, queryset): + """Notify user that their project's namespace has been requested via email""" + qs_iterator = queryset.iterator() + for project in qs_iterator: + users = project.users.get_queryset() + for user in users: + send_email( + recipient=user.email, + subject='Rename request for abandoned project', + template='projects/email/abandon_project.txt', + template_html='projects/email/abandon_project.html', + context={'proj_name': project.name} + ) + success_msg = 'Email sent to {}'.format(user.email) + self.message_user(request, success_msg, level=messages.SUCCESS) + + request_namespace.short_description = 'Request namespace' + def build_default_version(self, request, queryset): """Trigger a build for the project version.""" total = 0 diff --git a/readthedocs/projects/migrations/0042_add_is_abandoned_field.py b/readthedocs/projects/migrations/0042_add_is_abandoned_field.py new file mode 100644 index 00000000000..a6e69c72d5b --- /dev/null +++ b/readthedocs/projects/migrations/0042_add_is_abandoned_field.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.16 on 2018-12-13 10:35 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0041_index-repo-field'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='is_abandoned', + field=models.BooleanField(default=False, verbose_name='Is abandoned'), + ), + ] diff --git a/readthedocs/projects/models.py b/readthedocs/projects/models.py index e6f4c94fe4d..923134a2989 100644 --- a/readthedocs/projects/models.py +++ b/readthedocs/projects/models.py @@ -284,6 +284,7 @@ class Project(models.Model): ) featured = models.BooleanField(_('Featured'), default=False) + is_abandoned = models.BooleanField(_('Is abandoned'), default=False) skip = models.BooleanField(_('Skip'), default=False) install_project = models.BooleanField( _('Install Project'), diff --git a/readthedocs/projects/notifications.py b/readthedocs/projects/notifications.py index ec8ce299483..6b151c1f99b 100644 --- a/readthedocs/projects/notifications.py +++ b/readthedocs/projects/notifications.py @@ -2,9 +2,10 @@ """Project notifications.""" -from django.urls import reverse -from django.http import HttpRequest +from django.conf import settings from django.utils.translation import ugettext_lazy as _ +from django.http import HttpRequest +from django.urls import reverse from messages_extends.constants import ERROR_PERSISTENT from readthedocs.notifications import Notification, SiteNotification @@ -19,6 +20,31 @@ class ResourceUsageNotification(Notification): level = REQUIREMENT +class AbandonedProjectNotification(SiteNotification): + + level = REQUIREMENT + send_email = True + failure_level = ERROR_PERSISTENT + subject = 'Abandoned project {{ proj_name }}' + failure_message = _( + 'Your project {{ proj_name }} is marked as abandoned. Update link ' + 'for the project docs is {{ proj_url }}.' + ) + + def get_context_data(self): + context = super(AbandonedProjectNotification, self).get_context_data() + project = self.extra_context.get('project') + proj_url = '{base}{url}'.format( + base=settings.PRODUCTION_DOMAIN, + url=project.get_absolute_url() + ) + context.update({ + 'proj_name': project.name, + 'proj_url': proj_url + }) + return context + + class EmailConfirmNotification(SiteNotification): failure_level = ERROR_PERSISTENT diff --git a/readthedocs/projects/tasks.py b/readthedocs/projects/tasks.py index 1d220cfbfba..3e4afea26be 100644 --- a/readthedocs/projects/tasks.py +++ b/readthedocs/projects/tasks.py @@ -1582,6 +1582,13 @@ def finish_inactive_builds(): ) +@app.task() +def rename_project_dir(old_path, new_path): + """Renames the project's directory.""" + log.info('Moving %s to %s', old_path, new_path) + shutil.move(old_path, new_path) + + @app.task(queue='web') def retry_domain_verification(domain_pk): """ diff --git a/readthedocs/rtd_tests/tests/projects/test_admin_actions.py b/readthedocs/rtd_tests/tests/projects/test_admin_actions.py index dd25f4a13b4..62e69279581 100644 --- a/readthedocs/rtd_tests/tests/projects/test_admin_actions.py +++ b/readthedocs/rtd_tests/tests/projects/test_admin_actions.py @@ -1,10 +1,15 @@ # -*- coding: utf-8 -*- -import django_dynamic_fixture as fixture + import mock + from django import urls +import django_dynamic_fixture as fixture from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME from django.contrib.auth.models import User +from django.utils.translation import ugettext_lazy as _ from django.test import TestCase +from django.conf import settings +from messages_extends.models import Message as PersistentMessage from readthedocs.core.models import UserProfile from readthedocs.projects.models import Project @@ -78,3 +83,92 @@ def test_project_delete(self, broadcast): type='app', task=remove_dirs, args=[(self.project.doc_path,)], ), ]) + + @mock.patch('readthedocs.projects.admin.send_email') + def test_request_namespace(self, mock_send_email): + """Test the requesting of project's namespace""" + action_data = { + ACTION_CHECKBOX_NAME: [self.project.pk], + 'action': 'request_namespace', + } + resp = self.client.post( + urls.reverse('admin:projects_project_changelist'), + action_data + ) + + proj = self.project + self.assertEqual(proj.users.get_queryset().count(), 1) + user = proj.users.get_queryset().first() + + mock_send_email.assert_called_once_with( + recipient=user.email, + subject='Rename request for abandoned project', + template='projects/email/abandon_project.txt', + template_html='projects/email/abandon_project.html', + context={'proj_name': proj.name} + ) + + @mock.patch('readthedocs.projects.admin.broadcast') + @mock.patch('readthedocs.notifications.backends.send_email') + def test_mark_as_abandoned(self, mock_send_email, mock_broadcast): + """Test the marking of project as abandoned.""" + from readthedocs.projects.tasks import rename_project_dir + + project = fixture.get(Project, users=[self.admin]) + action_data = { + ACTION_CHECKBOX_NAME: [project.pk], + 'action': 'mark_as_abandoned', + } + + # before marking project as abandoned. + current_slug = project.slug + current_doc_path = project.doc_path + self.assertEqual(project.users.get_queryset().count(), 1) + user = project.users.get_queryset().first() + + self.assertEqual(PersistentMessage.objects.count(), 0) + + resp = self.client.post( + urls.reverse('admin:projects_project_changelist'), + action_data, + ) + + project.refresh_from_db() + + # after marking the project as abandoned. + new_doc_path = project.doc_path + new_slug = '{}-abandoned'.format(current_slug) + + self.assertEqual( + project.get_absolute_url(), + '/projects/{}/'.format(new_slug) + ) + new_url = '{base}{url}'.format( + base=settings.PRODUCTION_DOMAIN, + url=project.get_absolute_url() + ) + + self.assertTrue(project.is_abandoned, True) + self.assertEqual(project.slug, new_slug) + mock_broadcast.assert_called_once_with( + type='web', + task=rename_project_dir, + args=[current_doc_path, new_doc_path] + ) + failure_message = _( + 'Your project {name} is marked as abandoned. Update link ' + 'for the project docs is {url}.' + ) + # user must have to notified via email. + mock_send_email.assert_has_calls([ + mock.call( + template='core/email/common.txt', + context={'content': failure_message.format(name=project.name, url=new_url)}, + subject=u'Abandoned project {}'.format(project.name), + template_html='core/email/common.html', + recipient=user.email, + request=None + ) + ]) + # Persistent error msg is added to notify user. + self.assertEqual(PersistentMessage.objects.filter(read=False).count(), 1) diff --git a/readthedocs/rtd_tests/tests/test_celery.py b/readthedocs/rtd_tests/tests/test_celery.py index e5b9c207511..be45fe1057b 100644 --- a/readthedocs/rtd_tests/tests/test_celery.py +++ b/readthedocs/rtd_tests/tests/test_celery.py @@ -252,6 +252,18 @@ def public_task_exception(): }, ) + def test_rename_proj_dir(self): + directory = mkdtemp() + self.assertTrue(exists(directory)) + new_dir = os.path.join( + os.path.dirname(directory), + 'NewDirNameHere', + ) + self.assertFalse(exists(new_dir)) + tasks.rename_project_dir.delay(directory, new_dir) + self.assertFalse(exists(directory)) + self.assertTrue(exists(new_dir)) + @patch('readthedocs.builds.managers.log') def test_sync_files_logging_when_wrong_version_pk(self, mock_logger): self.assertFalse(Version.objects.filter(pk=345343).exists()) diff --git a/readthedocs/templates/core/project_detail_right.html b/readthedocs/templates/core/project_detail_right.html index 55ef2f4a172..e70973e37ae 100644 --- a/readthedocs/templates/core/project_detail_right.html +++ b/readthedocs/templates/core/project_detail_right.html @@ -4,6 +4,12 @@ {% load gravatar %} {% load projects_tags %} +{% block abandoned %} + {% if project.is_abandoned %} +
This project is abandoned.
+ {% endif %} +{% endblock %} + {% block repo %} {% if project.repo %}+ We've had a request from one of our users for the project name {{ proj_name }} on Read the Docs. You are the current owner, and we wanted to reach out to you in accordance with our Abandoned Project policy (http://docs.readthedocs.io/en/latest/abandoned-projects.html). +
+ ++ Since this is a fork of the existing owners project, {{ proj_name }}, you must show us the additional work that has been done in order to keep the new project name, that is different than the existing version of the project. +
+ ++ Please reply to this email either allowing or disallowing the transfer of the name on Read the Docs within 6 weeks, otherwise we will take the action of *initiating the transfer to a new owner* by default. +
+{% endblock %} diff --git a/readthedocs/templates/projects/email/abandon_project.txt b/readthedocs/templates/projects/email/abandon_project.txt new file mode 100644 index 00000000000..712da0c039a --- /dev/null +++ b/readthedocs/templates/projects/email/abandon_project.txt @@ -0,0 +1,6 @@ +{% extends "core/email/common.txt" %} +{% block content %} +We've had a request from one of our users for the project name {name} on Read the Docs. You are the current owner, and we wanted to reach out to you in accordance with our Abandoned Project policy (http://docs.readthedocs.io/en/latest/abandoned-projects.html). +Since this is a fork of the existing owners project, {name}, you must show us the additional work that has been done in order to keep the new project name, that is different than the existing version of the project. +Please reply to this email either allowing or disallowing the transfer of the name on Read the Docs within 6 weeks, otherwise we will take the action of *initiating the transfer to a new owner* by default. +{% endblock %}