1# Copyright 2013 The Distro Tracker Developers
2# See the COPYRIGHT file at the top-level directory of this distribution and
3# at https://deb.li/DTAuthors
4#
5# This file is part of Distro Tracker. It is subject to the license terms
6# in the LICENSE file found in the top-level directory of this
7# distribution and at https://deb.li/DTLicense. No part of Distro Tracker,
8# including this file, may be copied, modified, propagated, or distributed
9# except according to the terms contained in the LICENSE file.
10"""
11Settings for the admin panel for the models defined in the
12:mod:`distro_tracker.core` app.
13"""
14from django import forms
15from django.contrib import admin
16from django.core.exceptions import ValidationError
17from django.core.validators import URLValidator
19import requests
21from distro_tracker.core.models import (
22 Architecture,
23 RepositoryFlag,
24 RepositoryRelation
25)
26from distro_tracker.core.retrieve_data import (
27 InvalidRepositoryException,
28 retrieve_repository_info
29)
31from .models import Repository
34def validate_sources_list_entry(value):
35 """
36 A custom validator for the sources.list entry form field.
38 Makes sure that it follows the correct syntax and that the specified Web
39 resource is available.
41 :param value: The value of the sources.list entry which needs to be
42 validated
44 :raises ValidationError: Giving the validation failure message.
45 """
46 split = value.split(None, 3)
47 if len(split) < 3:
48 raise ValidationError("Invalid syntax: all parts not found.")
50 repository_type, url, name = split[:3]
51 if repository_type not in ('deb', 'deb-src'):
52 raise ValidationError(
53 "Invalid syntax: the line must start with deb or deb-src")
55 url_validator = URLValidator()
56 try:
57 url_validator(url)
58 except ValidationError:
59 raise ValidationError("Invalid repository URL")
61 # Check whether a Release file even exists.
62 if url.endswith('/'): 62 ↛ 64line 62 didn't jump to line 64, because the condition on line 62 was never false
63 url = url.rstrip('/')
64 try:
65 response = requests.head(Repository.release_file_url(url, name),
66 allow_redirects=True)
67 except requests.exceptions.Timeout: 67 ↛ 68line 67 didn't jump to line 68, because the exception caught by line 67 didn't happen
68 raise ValidationError(
69 "Invalid repository: Could not connect to {url}."
70 " Request timed out.".format(url=url))
71 except requests.exceptions.ConnectionError: 71 ↛ 76line 71 didn't jump to line 76
72 raise ValidationError(
73 "Invalid repository: Could not connect to {url} due to a network"
74 " problem. The URL may not exist or is refusing to receive"
75 " connections.".format(url=url))
76 except requests.exceptions.HTTPError:
77 raise ValidationError(
78 "Invalid repository:"
79 " Received an invalid HTTP response from {url}.".format(url=url))
80 except Exception:
81 raise ValidationError(
82 "Invalid repository: Could not connect to {url}".format(url=url))
84 if response.status_code != 200:
85 raise ValidationError(
86 "Invalid repository: No Release file found. "
87 "received a {status_code} HTTP response code.".format(
88 status_code=response.status_code))
91class RepositoryAdminForm(forms.ModelForm):
92 """
93 A custom :class:`ModelForm <django.forms.ModelForm>` used for creating and
94 modifying :class:`Repository <distro_tracker.core.models.Repository>` model
95 instances.
97 The class adds the ability to enter only a sources.list entry describing
98 the repository and other properties of the repository are automatically
99 filled in by using the ``Release`` file of the repository.
100 """
101 #: The additional form field which allows entring the sources.list entry
102 sources_list_entry = forms.CharField(
103 required=False,
104 help_text="You can enter a sources.list formatted entry and have the"
105 " rest of the fields automatically filled by using the "
106 "Release file of the repository.",
107 max_length=200,
108 widget=forms.TextInput(attrs={
109 'size': 100,
110 }),
111 validators=[
112 validate_sources_list_entry,
113 ]
114 )
116 flags = forms.MultipleChoiceField(
117 required=False,
118 widget=forms.CheckboxSelectMultiple, choices=RepositoryFlag.FLAG_NAMES)
120 class Meta:
121 model = Repository
122 exclude = (
123 'position',
124 'source_packages',
125 )
127 def __init__(self, *args, **kwargs):
128 # Inject initial data for flags field
129 initial = kwargs.get('initial', {})
130 instance = kwargs.get('instance', None)
131 if instance is None:
132 flags = RepositoryFlag.FLAG_DEFAULT_VALUES
133 else:
134 flags = instance.get_flags()
135 initial['flags'] = [
136 flag_name for flag_name in flags if flags[flag_name]
137 ]
138 kwargs['initial'] = initial
139 super(RepositoryAdminForm, self).__init__(*args, **kwargs)
140 # Fields can't be required if we want to support different methods of
141 # setting their value through the same form (sources.list and directly)
142 # The clean method makes sure that they are set in the end.
143 # So, save originally required fields in order to check them later.
144 self.original_required_fields = []
145 for name, field in self.fields.items():
146 if field.required:
147 field.required = False
148 self.original_required_fields.append(name)
149 # These fields are always required
150 self.fields['name'].required = True
151 self.fields['shorthand'].required = True
153 def clean(self, *args, **kwargs):
154 """
155 Overrides the :meth:`clean <django.forms.ModelForm.clean>` method of the
156 parent class to allow validating the form based on the sources.list
157 entry, not only the model fields.
158 """
159 self.cleaned_data = super(RepositoryAdminForm, self).clean(*args,
160 **kwargs)
161 if 'sources_list_entry' not in self.cleaned_data:
162 # Sources list entry was given to the form but it failed
163 # validation.
164 return self.cleaned_data
165 # Check if the entry was not even given
166 if not self.cleaned_data['sources_list_entry']:
167 # If not, all the fields required by the model must be found
168 # instead
169 for name in self.original_required_fields:
170 self.fields[name].required = True
171 self._clean_fields()
172 else:
173 # If it was given, need to make sure now that the Relase file
174 # contains usable data.
175 try:
176 repository_info = retrieve_repository_info(
177 self.cleaned_data['sources_list_entry'])
178 except InvalidRepositoryException:
179 raise ValidationError("The Release file was invalid.")
180 # Use the data to construct a Repository object.
181 self.cleaned_data.update(repository_info)
182 # Architectures have to be matched with their primary keys
183 self.cleaned_data['architectures'] = [
184 Architecture.objects.get(name=architecture_name).pk
185 for architecture_name in self.cleaned_data['architectures']
186 if Architecture.objects.filter(name=architecture_name).exists()
187 ]
189 return self.cleaned_data
192class RepositoryAdmin(admin.ModelAdmin):
193 """
194 Actual configuration for the
195 :class:`Repository <distro_tracker.core.models.Repository>`
196 admin panel.
197 """
198 class Media:
199 """
200 Add extra Javascript resources to the page in order to support
201 drag-and-drop repository position modification.
202 """
203 js = (
204 'js/jquery.min.js',
205 'js/jquery-ui.min.js',
206 'js/admin-list-reorder.js',
207 )
209 form = RepositoryAdminForm
211 # Sections the form in multiple parts
212 fieldsets = [
213 (None, {
214 'fields': [
215 'name',
216 'shorthand',
217 ]
218 }),
219 ('sources.list entry', {
220 'fields': [
221 'sources_list_entry',
222 ]
223 }),
224 ('Repository information', {
225 'fields': [
226 'uri',
227 'public_uri',
228 'codename',
229 'suite',
230 'components',
231 'architectures',
232 'default',
233 'optional',
234 'binary',
235 'source',
236 ]
237 }),
238 ('Repository flags', {
239 'fields': [
240 'flags',
241 ]
242 }),
243 ]
245 #: Gives a list of fields which should be displayed as columns in the
246 #: list of existing
247 #: :class:`Repository <distro_tracker.core.models.Repository>` instances.
248 list_display = (
249 'name',
250 'shorthand',
251 'codename',
252 'uri',
253 'components_string',
254 'architectures_string',
255 'default',
256 'optional',
257 'binary',
258 'source',
259 'position',
260 'flags_string',
261 )
263 ordering = (
264 'position',
265 )
267 list_editable = (
268 'position',
269 )
271 def save_model(self, request, obj, form, change):
272 if not change and obj.position == 0:
273 obj.position = Repository.objects.count() + 1
274 obj.save()
275 if 'flags' not in form.cleaned_data:
276 return
277 for flag in RepositoryFlag.FLAG_DEFAULT_VALUES:
278 value = flag in form.cleaned_data['flags']
279 try:
280 repo_flag = obj.flags.get(name=flag)
281 repo_flag.value = value
282 repo_flag.save()
283 except RepositoryFlag.DoesNotExist:
284 obj.flags.create(name=flag, value=value)
286 def components_string(self, obj):
287 """
288 Helper method for displaying Repository objects.
289 Turns the components list into a display-friendly string.
291 :param obj: The repository whose components are to be formatted
292 :type obj: :class:`Repository <distro_tracker.core.models.Repository>`
293 """
294 return ' '.join(obj.components)
295 components_string.short_description = 'components'
297 def architectures_string(self, obj):
298 """
299 Helper method for displaying Repository objects.
300 Turns the architectures list into a display-friendly string.
302 :param obj: The repository whose architectures are to be formatted
303 :type obj: :class:`Repository <distro_tracker.core.models.Repository>`
304 """
305 return ' '.join(map(str, obj.architectures.all()))
306 architectures_string.short_description = 'architectures'
308 def flags_string(self, obj):
309 return ' '.join("{}={}".format(x.name, x.value)
310 for x in obj.flags.all())
311 flags_string.short_description = 'Flags'
314class RepositoryRelationAdmin(admin.ModelAdmin):
315 """
316 Actual configuration for the
317 :class:`Repository <distro_tracker.core.models.RepositoryRelation>`
318 admin panel.
319 """
321 list_display = (
322 'repository',
323 'name',
324 'target_repository',
325 )
328admin.site.register(Repository, RepositoryAdmin)
329admin.site.register(RepositoryRelation, RepositoryRelationAdmin)