Skip to content

Commit 1e1d553

Browse files
authored
Project: allow connecting a project to a remote repository after it has been created (#11498)
* Project: allow connecting a project to a remote repo after it has been created Close #9437 * Add migration * Mark migration as safe * Tests * Fix tests * Updates from review
1 parent b902983 commit 1e1d553

File tree

6 files changed

+155
-65
lines changed

6 files changed

+155
-65
lines changed

readthedocs/oauth/models.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ class Meta:
186186
db_table = "oauth_remoterepository_2020"
187187

188188
def __str__(self):
189-
return self.html_url
189+
return self.html_url or self.full_name
190190

191191
def matches(self, user):
192192
"""Existing projects connected to this RemoteRepository."""

readthedocs/projects/forms.py

+53-63
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,62 @@ def __init__(self, *args, **kwargs):
5252
self.user = kwargs.pop("user", None)
5353
super().__init__(*args, **kwargs)
5454

55+
self.fields["repo"].widget.attrs["placeholder"] = self.placehold_repo()
56+
self.fields["repo"].widget.attrs["required"] = True
57+
58+
queryset = RemoteRepository.objects.for_project_linking(self.user)
59+
current_remote_repo = (
60+
self.instance.remote_repository if self.instance.pk else None
61+
)
62+
# If there is a remote repo attached to the project, add it to the queryset,
63+
# since the current user might not have access to it.
64+
if current_remote_repo:
65+
queryset |= RemoteRepository.objects.filter(
66+
pk=current_remote_repo.pk
67+
).distinct()
68+
self.fields["remote_repository"].queryset = queryset
69+
self.fields["remote_repository"].empty_label = _("No connected repository")
70+
5571
def save(self, commit=True):
5672
project = super().save(commit)
5773
if commit:
5874
if self.user and not project.users.filter(pk=self.user.pk).exists():
5975
project.users.add(self.user)
6076
return project
6177

78+
def clean_name(self):
79+
name = self.cleaned_data.get("name", "")
80+
if not self.instance.pk:
81+
potential_slug = slugify(name)
82+
if Project.objects.filter(slug=potential_slug).exists():
83+
raise forms.ValidationError(
84+
_("Invalid project name, a project already exists with that name"),
85+
) # yapf: disable # noqa
86+
if not potential_slug:
87+
# Check the generated slug won't be empty
88+
raise forms.ValidationError(
89+
_("Invalid project name"),
90+
)
91+
92+
return name
93+
94+
def clean_repo(self):
95+
repo = self.cleaned_data.get("repo", "")
96+
return repo.rstrip("/")
97+
98+
def placehold_repo(self):
99+
return choice(
100+
[
101+
"https://bitbucket.org/cherrypy/cherrypy",
102+
"https://bitbucket.org/birkenfeld/sphinx",
103+
"https://bitbucket.org/hpk42/tox",
104+
"https://github.com/zzzeek/sqlalchemy.git",
105+
"https://github.com/django/django.git",
106+
"https://github.com/fabric/fabric.git",
107+
"https://github.com/ericholscher/django-kong.git",
108+
]
109+
)
110+
62111

63112
class ProjectTriggerBuildMixin:
64113

@@ -313,73 +362,13 @@ class ProjectBasicsForm(ProjectForm):
313362

314363
class Meta:
315364
model = Project
316-
fields = ("name", "repo", "default_branch", "language")
317-
318-
remote_repository = forms.IntegerField(
319-
widget=forms.HiddenInput(),
320-
required=False,
321-
)
365+
fields = ("name", "repo", "default_branch", "language", "remote_repository")
322366

323367
def __init__(self, *args, **kwargs):
324368
super().__init__(*args, **kwargs)
325369
self.fields["repo"].widget.attrs["placeholder"] = self.placehold_repo()
326370
self.fields["repo"].widget.attrs["required"] = True
327-
328-
def save(self, commit=True):
329-
"""Add remote repository relationship to the project instance."""
330-
instance = super().save(commit)
331-
remote_repo = self.cleaned_data.get("remote_repository", None)
332-
if remote_repo:
333-
if commit:
334-
remote_repo.projects.add(self.instance)
335-
remote_repo.save()
336-
else:
337-
instance.remote_repository = remote_repo
338-
return instance
339-
340-
def clean_name(self):
341-
name = self.cleaned_data.get("name", "")
342-
if not self.instance.pk:
343-
potential_slug = slugify(name)
344-
if Project.objects.filter(slug=potential_slug).exists():
345-
raise forms.ValidationError(
346-
_("Invalid project name, a project already exists with that name"),
347-
) # yapf: disable # noqa
348-
if not potential_slug:
349-
# Check the generated slug won't be empty
350-
raise forms.ValidationError(
351-
_("Invalid project name"),
352-
)
353-
354-
return name
355-
356-
def clean_repo(self):
357-
repo = self.cleaned_data.get("repo", "")
358-
return repo.rstrip("/")
359-
360-
def clean_remote_repository(self):
361-
remote_repo = self.cleaned_data.get("remote_repository", None)
362-
if not remote_repo:
363-
return None
364-
try:
365-
return RemoteRepository.objects.for_project_linking(self.user).get(
366-
pk=remote_repo,
367-
)
368-
except RemoteRepository.DoesNotExist as exc:
369-
raise forms.ValidationError(_("Repository invalid")) from exc
370-
371-
def placehold_repo(self):
372-
return choice(
373-
[
374-
"https://bitbucket.org/cherrypy/cherrypy",
375-
"https://bitbucket.org/birkenfeld/sphinx",
376-
"https://bitbucket.org/hpk42/tox",
377-
"https://github.com/zzzeek/sqlalchemy.git",
378-
"https://github.com/django/django.git",
379-
"https://github.com/fabric/fabric.git",
380-
"https://github.com/ericholscher/django-kong.git",
381-
]
382-
)
371+
self.fields["remote_repository"].widget = forms.HiddenInput()
383372

384373

385374
class ProjectConfigForm(forms.Form):
@@ -394,7 +383,7 @@ def __init__(self, *args, **kwargs):
394383

395384
class UpdateProjectForm(
396385
ProjectTriggerBuildMixin,
397-
ProjectBasicsForm,
386+
ProjectForm,
398387
ProjectPRBuildsMixin,
399388
):
400389

@@ -406,6 +395,7 @@ class Meta:
406395
# Basics and repo settings
407396
"name",
408397
"repo",
398+
"remote_repository",
409399
"language",
410400
"default_version",
411401
"privacy_level",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Generated by Django 4.2.13 on 2024-07-24 21:57
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
from django_safemigrate import Safe
6+
7+
8+
class Migration(migrations.Migration):
9+
safe = Safe.before_deploy
10+
dependencies = [
11+
("oauth", "0016_deprecate_old_vcs"),
12+
("projects", "0125_update_naming"),
13+
]
14+
15+
operations = [
16+
migrations.AlterField(
17+
model_name="historicalproject",
18+
name="remote_repository",
19+
field=models.ForeignKey(
20+
blank=True,
21+
db_constraint=False,
22+
verbose_name="Connected repository",
23+
help_text="Repository connected to this project",
24+
null=True,
25+
on_delete=django.db.models.deletion.DO_NOTHING,
26+
related_name="+",
27+
to="oauth.remoterepository",
28+
),
29+
),
30+
migrations.AlterField(
31+
model_name="project",
32+
name="remote_repository",
33+
field=models.ForeignKey(
34+
blank=True,
35+
verbose_name="Connected repository",
36+
help_text="Repository connected to this project",
37+
null=True,
38+
on_delete=django.db.models.deletion.SET_NULL,
39+
related_name="projects",
40+
to="oauth.remoterepository",
41+
),
42+
),
43+
]

readthedocs/projects/models.py

+2
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,8 @@ class Project(models.Model):
540540

541541
remote_repository = models.ForeignKey(
542542
"oauth.RemoteRepository",
543+
verbose_name=_("Connected repository"),
544+
help_text=_("Repository connected to this project"),
543545
on_delete=models.SET_NULL,
544546
related_name="projects",
545547
null=True,

readthedocs/projects/views/private.py

+4
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,10 @@ class ProjectUpdate(ProjectMixin, UpdateView):
200200
def get_success_url(self):
201201
return reverse("projects_detail", args=[self.object.slug])
202202

203+
def get_form(self, data=None, files=None, **kwargs):
204+
kwargs["user"] = self.request.user
205+
return super().get_form(data, files, **kwargs)
206+
203207

204208
class ProjectDelete(UpdateChangeReasonPostView, ProjectMixin, DeleteViewWithMessage):
205209
success_message = _("Project deleted")

readthedocs/rtd_tests/tests/test_project_forms.py

+52-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from readthedocs.builds.constants import EXTERNAL, LATEST, STABLE
1212
from readthedocs.builds.models import Version
1313
from readthedocs.core.forms import RichValidationError
14+
from readthedocs.oauth.models import RemoteRepository, RemoteRepositoryRelation
1415
from readthedocs.organizations.models import Organization, Team
1516
from readthedocs.projects.constants import (
1617
ADDONS_FLYOUT_SORTING_CALVER,
@@ -156,7 +157,8 @@ def test_strip_repo_url(self):
156157

157158
class TestProjectAdvancedForm(TestCase):
158159
def setUp(self):
159-
self.project = get(Project, privacy_level=PUBLIC)
160+
self.user = get(User)
161+
self.project = get(Project, privacy_level=PUBLIC, users=[self.user])
160162
get(
161163
Version,
162164
project=self.project,
@@ -333,6 +335,55 @@ def test_trigger_build_on_save(self, trigger_build):
333335
version=default_branch,
334336
)
335337

338+
def test_set_remote_repository(self):
339+
data = {
340+
"name": "Project",
341+
"repo": "https://github.com/readthedocs/readthedocs.org/",
342+
"repo_type": self.project.repo_type,
343+
"default_version": LATEST,
344+
"language": self.project.language,
345+
"versioning_scheme": self.project.versioning_scheme,
346+
}
347+
348+
remote_repository = get(
349+
RemoteRepository,
350+
full_name="rtfd/template",
351+
clone_url="https://github.com/rtfd/template",
352+
html_url="https://github.com/rtfd/template",
353+
ssh_url="[email protected]:rtfd/template.git",
354+
private=False,
355+
)
356+
357+
# No remote repository attached.
358+
form = UpdateProjectForm(data, instance=self.project, user=self.user)
359+
self.assertTrue(form.is_valid())
360+
361+
# Remote repository attached, but it doesn't belong to the user.
362+
data["remote_repository"] = remote_repository.pk
363+
form = UpdateProjectForm(data, instance=self.project, user=self.user)
364+
self.assertFalse(form.is_valid())
365+
self.assertIn("remote_repository", form.errors)
366+
367+
# Remote repository attached, it belongs to the user now.
368+
remote_repository_rel = get(
369+
RemoteRepositoryRelation,
370+
remote_repository=remote_repository,
371+
user=self.user,
372+
admin=True,
373+
)
374+
data["remote_repository"] = remote_repository.pk
375+
form = UpdateProjectForm(data, instance=self.project, user=self.user)
376+
self.assertTrue(form.is_valid())
377+
378+
# The project has the remote repository attached.
379+
# And the user doesn't have access to it anymore, but still can use it.
380+
self.project.remote_repository = remote_repository
381+
self.project.save()
382+
remote_repository_rel.delete()
383+
data["remote_repository"] = remote_repository.pk
384+
form = UpdateProjectForm(data, instance=self.project, user=self.user)
385+
self.assertTrue(form.is_valid())
386+
336387

337388
class TestProjectAdvancedFormDefaultBranch(TestCase):
338389
def setUp(self):

0 commit comments

Comments
 (0)