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 

18 

19import requests 

20 

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) 

30 

31from .models import Repository 

32 

33 

34def validate_sources_list_entry(value): 

35 """ 

36 A custom validator for the sources.list entry form field. 

37 

38 Makes sure that it follows the correct syntax and that the specified Web 

39 resource is available. 

40 

41 :param value: The value of the sources.list entry which needs to be 

42 validated 

43 

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

49 

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") 

54 

55 url_validator = URLValidator() 

56 try: 

57 url_validator(url) 

58 except ValidationError: 

59 raise ValidationError("Invalid repository URL") 

60 

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

83 

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

89 

90 

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. 

96 

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 ) 

115 

116 flags = forms.MultipleChoiceField( 

117 required=False, 

118 widget=forms.CheckboxSelectMultiple, choices=RepositoryFlag.FLAG_NAMES) 

119 

120 class Meta: 

121 model = Repository 

122 exclude = ( 

123 'position', 

124 'source_packages', 

125 ) 

126 

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 

152 

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 ] 

188 

189 return self.cleaned_data 

190 

191 

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 ) 

208 

209 form = RepositoryAdminForm 

210 

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 ] 

244 

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 ) 

262 

263 ordering = ( 

264 'position', 

265 ) 

266 

267 list_editable = ( 

268 'position', 

269 ) 

270 

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) 

285 

286 def components_string(self, obj): 

287 """ 

288 Helper method for displaying Repository objects. 

289 Turns the components list into a display-friendly string. 

290 

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' 

296 

297 def architectures_string(self, obj): 

298 """ 

299 Helper method for displaying Repository objects. 

300 Turns the architectures list into a display-friendly string. 

301 

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' 

307 

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' 

312 

313 

314class RepositoryRelationAdmin(admin.ModelAdmin): 

315 """ 

316 Actual configuration for the 

317 :class:`Repository <distro_tracker.core.models.RepositoryRelation>` 

318 admin panel. 

319 """ 

320 

321 list_display = ( 

322 'repository', 

323 'name', 

324 'target_repository', 

325 ) 

326 

327 

328admin.site.register(Repository, RepositoryAdmin) 

329admin.site.register(RepositoryRelation, RepositoryRelationAdmin)