66
77from django .core .exceptions import ValidationError
88from django .core .validators import MaxValueValidator , MinValueValidator
9- from django .db .models import BooleanField , CharField , Index , PositiveIntegerField
9+ from django .db .models import BooleanField , CharField , Index , PositiveIntegerField , UniqueConstraint
10+ from django .db .models .functions import Lower
1011
1112# Ignoring the N811 error as this is an external library and we cannot change its name
1213# We are already using "URL" in our own code so we need to alias this import
@@ -60,6 +61,10 @@ def parse(self, value: str) -> ParsedUrl:
6061 error_message = f"No host provided in URL: { parsed_url } "
6162 raise ValidationError (error_message )
6263
64+ if parsed_url .port is not None and (parsed_url .port < 1 or parsed_url .port > 65535 ):
65+ error_message = f"Invalid port: { parsed_url .port } "
66+ raise ValidationError (error_message )
67+
6368 return ParsedUrl (
6469 raw = value ,
6570 protocol = parsed_url .scheme ,
@@ -162,6 +167,19 @@ class Meta:
162167 verbose_name = "Locations - URL"
163168 verbose_name_plural = "Locations - URLs"
164169 indexes = (Index (fields = ["host" ]),)
170+ constraints = [
171+ UniqueConstraint (
172+ Lower ("protocol" ),
173+ "user_info" ,
174+ Lower ("host" ),
175+ "port" ,
176+ "path" ,
177+ "query" ,
178+ "fragment" ,
179+ "host_validation_failure" ,
180+ name = "url_unique" ,
181+ ),
182+ ]
165183
166184 def manual_str (self ):
167185 value = ""
@@ -206,10 +224,19 @@ def get_location_type(cls) -> str:
206224 def get_location_value (self ) -> str :
207225 return str (self )
208226
227+ def normalize_url_parts (self ):
228+ self .clean_protocol ()
229+ self .clean_user_info ()
230+ self .clean_host ()
231+ self .clean_port ()
232+ self .clean_path ()
233+ self .clean_query ()
234+ self .clean_fragment ()
235+ self .clean_host_validation_failure ()
236+
209237 def pre_save_logic (self ) -> None :
210238 """Allow for some pre save operations by other classes."""
211- # Set default port based on protocol if not provided
212- self .clean_port ()
239+ self .normalize_url_parts ()
213240 super ().pre_save_logic ()
214241
215242 @staticmethod
@@ -220,11 +247,25 @@ def _parse_string_value(value: str) -> ParsedUrl:
220247 def clean (self , * args : list , ** kwargs : dict ) -> None :
221248 """Validate the input supplied."""
222249 super ().clean (* args , ** kwargs )
223- # Ensure the full value is correctly parsable. If not, an exception will be raised
224- self .clean_port ()
225- self .clean_path ()
226- self .clean_query ()
227- self .clean_fragment ()
250+ self .normalize_url_parts ()
251+
252+ def clean_protocol (self ) -> None :
253+ if not self .protocol :
254+ self .protocol = ""
255+ else :
256+ self .protocol = self .protocol .lower ()
257+
258+ def clean_user_info (self ):
259+ if not self .user_info :
260+ self .user_info = ""
261+ else :
262+ self .user_info = self .remove_null_bytes (self .user_info .strip ())
263+
264+ def clean_host (self ) -> None :
265+ if not self .host :
266+ self .host = ""
267+ else :
268+ self .host = self .host .lower ()
228269
229270 def clean_port (self ) -> None :
230271 if self .port is None :
@@ -249,15 +290,54 @@ def clean_query(self) -> None:
249290 else :
250291 self .query = self .remove_null_bytes (self .query .strip ().removeprefix ("?" ))
251292
293+ def clean_host_validation_failure (self ):
294+ self .host_validation_failure = bool (self .host_validation_failure )
295+
252296 def remove_null_bytes (self , value : str ) -> str :
253297 return value .replace ("\x00 " , "%00" )
254298
299+ @staticmethod
300+ def get_or_create_from_object (url : URL ) -> URL :
301+ url .normalize_url_parts ()
302+ url , _ = URL .objects .get_or_create (
303+ protocol = url .protocol ,
304+ user_info = url .user_info ,
305+ host = url .host ,
306+ port = url .port ,
307+ path = url .path ,
308+ query = url .query ,
309+ fragment = url .fragment ,
310+ host_validation_failure = url .host_validation_failure ,
311+ )
312+ return url
313+
314+ @staticmethod
315+ def get_or_create_from_values (
316+ protocol = None ,
317+ user_info = None ,
318+ host = None ,
319+ port = None ,
320+ path = None ,
321+ query = None ,
322+ fragment = None ,
323+ host_validation_failure = None ,
324+ ) -> URL :
325+ return URL .get_or_create_from_object (URL (
326+ protocol = protocol ,
327+ user_info = user_info ,
328+ host = host ,
329+ port = port ,
330+ path = path ,
331+ query = query ,
332+ fragment = fragment ,
333+ host_validation_failure = host_validation_failure ,
334+ ))
335+
255336 @staticmethod
256337 def create_location_from_value (value : str ) -> URL :
257338 """Parse a string URL and return the resulting *persisted* URL Model."""
258- url = URL .from_value (value )
259- url .save ()
260- return url
339+ unsaved_url = URL .from_value (value )
340+ return URL .get_or_create_from_object (unsaved_url )
261341
262342 @staticmethod
263343 def from_value (value : str ) -> URL :
@@ -280,5 +360,5 @@ def from_value(value: str) -> URL:
280360 query = query ,
281361 fragment = fragment ,
282362 )
283- url .full_clean ()
363+ url .normalize_url_parts ()
284364 return url
0 commit comments