diff --git a/readthedocs/projects/forms.py b/readthedocs/projects/forms.py index caaeef9d0de..3c5952a6b76 100644 --- a/readthedocs/projects/forms.py +++ b/readthedocs/projects/forms.py @@ -114,9 +114,13 @@ def clean_name(self): name = self.cleaned_data.get('name', '') if not self.instance.pk: potential_slug = slugify(name) - if Project.objects.filter(slug=potential_slug).exists(): - raise forms.ValidationError( - _('Invalid project name, a project already exists with that name')) # yapf: disable # noqa + project_exist = Project.objects.filter(slug=potential_slug).exists() + if project_exist: + project = Project.objects.get(slug=potential_slug) + project_url = project.get_absolute_url() + err_msg = ('Invalid project name, a ' + 'project already exists with that name').format(project_url) + raise forms.ValidationError(mark_safe(err_msg)) # yapf: disable # noqa return name def clean_repo(self): diff --git a/readthedocs/projects/migrations/0024_add_abandon_mail_sent.py b/readthedocs/projects/migrations/0024_add_abandon_mail_sent.py new file mode 100644 index 00000000000..3f1ab9b80c5 --- /dev/null +++ b/readthedocs/projects/migrations/0024_add_abandon_mail_sent.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.12 on 2018-03-04 09:24 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0023_migrate-alias-slug'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='abandoned_mail_sent', + field=models.BooleanField(default=False), + ), + + ] diff --git a/readthedocs/projects/models.py b/readthedocs/projects/models.py index 5ff628e899f..9847d644d37 100644 --- a/readthedocs/projects/models.py +++ b/readthedocs/projects/models.py @@ -8,6 +8,7 @@ import logging import os from builtins import object # pylint: disable=redefined-builtin +from datetime import datetime, timedelta from django.conf import settings from django.contrib.auth.models import User @@ -82,6 +83,7 @@ class Project(models.Model): related_name='projects') name = models.CharField(_('Name'), max_length=255) slug = models.SlugField(_('Slug'), max_length=255, unique=True) + abandoned_mail_sent = models.BooleanField(default=False) description = models.TextField(_('Description'), blank=True, help_text=_('The reStructuredText ' 'description of the project')) @@ -558,6 +560,22 @@ def conf_dir(self, version=LATEST): if conf_file: return os.path.dirname(conf_file) + @property + def is_abandoned(self): + """Is project abandoned.""" + if self.has_good_build: + latest_build = self.get_latest_build() + if latest_build: + latest_build_date = latest_build.date + today = datetime.today() + diff = today - latest_build_date + # Latest build a year ago. + if diff > timedelta(days=365): + return True + return False + return False + return True + @property def is_type_sphinx(self): """Is project type Sphinx.""" diff --git a/readthedocs/projects/urls/private.py b/readthedocs/projects/urls/private.py index 7033e71a566..fc263fc474b 100644 --- a/readthedocs/projects/urls/private.py +++ b/readthedocs/projects/urls/private.py @@ -36,6 +36,10 @@ private.project_manage, name='projects_manage'), + url(r'^(?P[-\w]+)/send_abandoned_mail/$', + private.send_abandoned_mail, + name='send_abandoned_mail'), + url(r'^(?P[-\w]+)/comments_moderation/$', private.project_comments_moderation, name='projects_comments_moderation'), diff --git a/readthedocs/projects/views/private.py b/readthedocs/projects/views/private.py index 6db70fb6af5..dd24e4cd058 100644 --- a/readthedocs/projects/views/private.py +++ b/readthedocs/projects/views/private.py @@ -8,7 +8,7 @@ from allauth.socialaccount.models import SocialAccount from django.contrib import messages -from django.contrib.auth.decorators import login_required +from django.contrib.auth.decorators import login_required, user_passes_test from django.contrib.auth.models import User from django.core.urlresolvers import reverse from django.http import ( @@ -26,7 +26,8 @@ from readthedocs.builds.forms import AliasForm, VersionForm from readthedocs.builds.models import Version, VersionAlias from readthedocs.core.mixins import ListViewWithForm, LoginRequiredMixin -from readthedocs.core.utils import broadcast, trigger_build +from readthedocs.core.utils import broadcast, trigger_build, send_email +from readthedocs.core.permissions import AdminPermission from readthedocs.integrations.models import HttpExchange, Integration from readthedocs.oauth.services import registry from readthedocs.oauth.utils import attach_webhook, update_webhook @@ -264,6 +265,27 @@ def is_advanced(self): return data.get('advanced', True) +@login_required +@user_passes_test(lambda u: u.is_superuser) +def send_abandoned_mail(request, project_slug): + """Sends abandoned project email.""" + project = Project.objects.get(slug=project_slug) + proj_name = project_slug + context = {'proj_name': proj_name} + subject = 'Rename request for abandoned project' + for user in project.users.all(): + email = user.email + send_email( + recipient=email, + subject=subject, + template='projects/email/abandon_project.txt', + template_html='projects/email/abandon_project.html', + context=context) + project.abandoned_mail_sent = True + project.save() + return HttpResponseRedirect(request.META.get('HTTP_REFERER', '/')) + + class ImportDemoView(PrivateViewMixin, View): """View to pass request on to import form to import demo project.""" diff --git a/readthedocs/rtd_tests/tests/test_privacy_urls.py b/readthedocs/rtd_tests/tests/test_privacy_urls.py index e992a55ae1c..2991c3dd713 100644 --- a/readthedocs/rtd_tests/tests/test_privacy_urls.py +++ b/readthedocs/rtd_tests/tests/test_privacy_urls.py @@ -219,6 +219,7 @@ class PrivateProjectAdminAccessTest(PrivateProjectMixin, TestCase): # Places where we 302 on success -- These delete pages should probably be 405'ing '/dashboard/import/manual/demo/': {'status_code': 302}, '/dashboard/pip/': {'status_code': 302}, + '/dashboard/pip/send_abandoned_mail/': {'status_code':302}, '/dashboard/pip/subprojects/delete/sub/': {'status_code': 302}, '/dashboard/pip/translations/delete/sub/': {'status_code': 302}, @@ -253,6 +254,7 @@ class PrivateProjectUserAccessTest(PrivateProjectMixin, TestCase): # Unauth access redirect for non-owners '/dashboard/pip/': {'status_code': 302}, + '/dashboard/pip/send_abandoned_mail/': {'status_code':302}, # 405's where we should be POST'ing '/dashboard/pip/users/delete/': {'status_code': 405}, diff --git a/readthedocs/rtd_tests/tests/test_project.py b/readthedocs/rtd_tests/tests/test_project.py index 38e7943634d..e4a9933fb69 100644 --- a/readthedocs/rtd_tests/tests/test_project.py +++ b/readthedocs/rtd_tests/tests/test_project.py @@ -1,3 +1,4 @@ + # -*- coding: utf-8 -*- from __future__ import ( absolute_import, division, print_function, unicode_literals) @@ -216,3 +217,78 @@ def test_finish_inactive_builds_task(self): self.assertTrue(self.build_3.success) self.assertEqual(self.build_3.error, '') self.assertEqual(self.build_3.state, BUILD_STATE_TRIGGERED) + + +class TestAbandonedProject(TestCase): + fixtures = ['eric', 'test_data'] + + def setUp(self): + self.client.login(username='eric', password='test') + self.pip = Project.objects.get(slug='pip') + self.taggit = Project.objects.get(slug='taggit') + self.pinax = Project.objects.get(slug='pinax') + + self.build_1 = Build.objects.create( + project=self.pip, + version=self.pip.get_stable_version(), + state=BUILD_STATE_FINISHED, + ) + + self.build_1.date = ( + datetime.datetime.now() - datetime.timedelta(days=750)) + self.build_1.success = False + self.build_1.save() + + self.build_2 = Build.objects.create( + project=self.pip, + version=self.pip.get_stable_version(), + state=BUILD_STATE_FINISHED, + ) + + self.build_2.success = True + self.build_2.save() + + self.build_3 = Build.objects.create( + project=self.taggit, + version=self.taggit.get_stable_version(), + state=BUILD_STATE_FINISHED, + ) + + self.build_3.date = ( + datetime.datetime.now() - datetime.timedelta(days=2)) + self.build_3.success = False + self.build_3.save() + + self.build_4 = Build.objects.create( + project=self.taggit, + version=self.taggit.get_stable_version(), + state=BUILD_STATE_FINISHED, + ) + + self.build_4.success = False + self.build_4.save() + + self.build_5 = Build.objects.create( + project=self.pinax, + version=self.pinax.get_stable_version(), + state=BUILD_STATE_FINISHED, + ) + + self.build_5.success = False + self.build_5.save() + + self.build_6 = Build.objects.create( + project=self.pinax, + version=self.pinax.get_stable_version(), + state=BUILD_STATE_FINISHED, + ) + + self.build_6.date = ( + datetime.datetime.now() - datetime.timedelta(days=750)) + self.build_6.success = True + self.build_6.save() + + def test_abandoned_project(self): + self.assertFalse(self.pip.is_abandoned) + self.assertTrue(self.taggit.is_abandoned) + self.assertFalse(self.pinax.is_abandoned) diff --git a/readthedocs/templates/core/project_bar_base.html b/readthedocs/templates/core/project_bar_base.html index 1224442eed4..1cffc49e319 100644 --- a/readthedocs/templates/core/project_bar_base.html +++ b/readthedocs/templates/core/project_bar_base.html @@ -23,6 +23,19 @@

{{ project }}

+ + {% if project.is_abandoned and request.user.is_superuser %} +
+ {% if not project.abandoned_mail_sent %} +
+ {% csrf_token %} + +
+ {% else %} +

{%trans "Abandonment mail was sent to the owner of the project." %}

+ {% endif %} +
+ {% endif %}
diff --git a/readthedocs/templates/projects/email/abandon_project.html b/readthedocs/templates/projects/email/abandon_project.html new file mode 100644 index 00000000000..d80f2d1b9de --- /dev/null +++ b/readthedocs/templates/projects/email/abandon_project.html @@ -0,0 +1,13 @@ +{% extends "core/email/common.html" %} + +{% load i18n %} + +{% block content %} +

+ 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). +

+ +

+ Please reply at hello@readthedocs.com 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..9398e1ec077 --- /dev/null +++ b/readthedocs/templates/projects/email/abandon_project.txt @@ -0,0 +1,9 @@ +{% extends "core/email/common.txt" %} + +{% load i18n %} + +{% block content %} +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). + +Please reply at hello@readthedocs.com 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 %}