diff --git a/pokemon_v2/migrations/0026_alter_ability_name_alter_abilityname_name_and_more.py b/pokemon_v2/migrations/0026_alter_ability_name_alter_abilityname_name_and_more.py new file mode 100644 index 000000000..62b3bc71b --- /dev/null +++ b/pokemon_v2/migrations/0026_alter_ability_name_alter_abilityname_name_and_more.py @@ -0,0 +1,743 @@ +# Generated by Django 5.2.10 on 2026-04-02 09:30 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("pokemon_v2", "0025_pokemonstatpast"), + ] + + operations = [ + migrations.AlterField( + model_name="ability", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="abilityname", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="berry", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="berryfirmness", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="berryfirmnessname", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="berryflavor", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="berryflavorname", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="contesttype", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="contesttypename", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="egggroup", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="egggroupname", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="encountercondition", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="encounterconditionname", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="encounterconditionvalue", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="encounterconditionvaluename", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="encountermethod", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="encountermethodname", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="evolutiontrigger", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="evolutiontriggername", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="gender", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="generation", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="generationname", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="growthrate", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="item", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="itemattribute", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="itemattributename", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="itemcategory", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="itemcategoryname", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="itemflingeffect", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="itemname", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="itempocket", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="itempocketname", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="language", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="languagename", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="location", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="locationarea", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="locationareaname", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="locationname", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="move", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="moveattribute", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="moveattributename", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="movebattlestyle", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="movebattlestylename", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="movedamageclass", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="movedamageclassname", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="movelearnmethod", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="movelearnmethodname", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="movemetaailment", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="movemetaailmentname", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="movemetacategory", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="movename", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="movetarget", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="movetargetname", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="nature", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="naturename", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="palparkarea", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="palparkareaname", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="pokeathlonstat", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="pokeathlonstatname", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="pokedex", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="pokedexname", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="pokemon", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="pokemoncolor", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="pokemoncolorname", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="pokemonform", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="pokemonformname", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="pokemonhabitat", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="pokemonhabitatname", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="pokemonshape", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="pokemonshapename", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="pokemonspecies", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="pokemonspeciesname", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="region", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="regionname", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="stat", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="statname", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="type", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="typename", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="version", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="versiongroup", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + migrations.AlterField( + model_name="versionname", + name="name", + field=models.CharField( + db_index=True, + max_length=200, + validators=[django.core.validators.RegexValidator("^[a-z0-9-]+$")], + ), + ), + ] diff --git a/pokemon_v2/models.py b/pokemon_v2/models.py index 5fd7c2ae5..bfea1cf41 100644 --- a/pokemon_v2/models.py +++ b/pokemon_v2/models.py @@ -1,5 +1,8 @@ +from django.core.validators import RegexValidator from django.db import models +IDENTIFIER_PATTERN = r"^[a-z0-9-]+$" + ##################### # ABSTRACT MODELS # ##################### @@ -377,7 +380,11 @@ class Meta: class HasName(models.Model): - name = models.CharField(max_length=200, db_index=True) + name = models.CharField( + max_length=200, + db_index=True, + validators=[RegexValidator(IDENTIFIER_PATTERN)], + ) class Meta: abstract = True diff --git a/pokemon_v2/test_models.py b/pokemon_v2/test_models.py index 84f2dbf72..88d367288 100644 --- a/pokemon_v2/test_models.py +++ b/pokemon_v2/test_models.py @@ -1,3 +1,7 @@ +import csv +import os +import re +from django.conf import settings from django.test import TestCase from pokemon_v2.models import * @@ -9,3 +13,145 @@ def setUp(self): def fields_are_valid(self): smell = Ability.objects.get(name="Smell") self.assertEqual(smell.generation_id, 3) + + +class CSVResourceNameValidationTestCase(TestCase): + """ + Test that all resource identifiers in CSV files follow ASCII slug format. + + Resource identifiers are used in API URLs and should be URL-safe ASCII slugs + (lowercase letters, numbers, and hyphens only). + + This test validates the data source (CSV files) before it's loaded into the database. + """ + + VALID_IDENTIFIER_PATTERN = re.compile(IDENTIFIER_PATTERN) + + def test_all_csv_identifiers_are_ascii_slugs(self): + """ + Validate that all resource identifiers in CSV files follow the ASCII slug format. + + Identifiers should only contain: + - Lowercase letters (a-z) + - Numbers (0-9) + - Hyphens (-) + + This test will fail if any CSV contains identifiers with: + - Unicode characters (ñ, ', é, etc.) + - Uppercase letters + - Spaces + - Special characters (&, (), ', etc.) + """ + violations = [] + csv_dir = os.path.join(settings.BASE_DIR, "data", "v2", "csv") + + for filename in sorted(os.listdir(csv_dir)): + if not filename.endswith(".csv"): + continue + + csv_path = os.path.join(csv_dir, filename) + + try: + with open(csv_path, "r", encoding="utf-8") as csvfile: + reader = csv.DictReader(csvfile) + + if "identifier" not in reader.fieldnames: + continue + + for row_num, row in enumerate(reader, start=2): + identifier = row.get("identifier", "").strip() + + # Skip empty identifiers + if not identifier: + continue + + # Check if identifier matches the pattern + if not self.VALID_IDENTIFIER_PATTERN.match(identifier): + violations.append( + { + "file": filename, + "row": row_num, + "id": row.get("id", "N/A"), + "identifier": identifier, + } + ) + + except Exception as e: + violations.append( + { + "file": filename, + "row": "N/A", + "id": "N/A", + "identifier": f"Error reading file: {str(e)}", + } + ) + + error_lines = [] + + # Report violations + if violations: + error_lines.append( + "\n\nFound {} resource(s) with invalid identifiers (not ASCII slugs):".format( + len(violations) + ) + ) + error_lines.append("\nIdentifiers must match pattern: ^[a-z0-9-]+$") + error_lines.append("\nInvalid identifiers found in CSV files:") + + for v in violations: + error_lines.append( + " - {file} (row {row}, id={id}): {identifier}".format(**v) + ) + + error_lines.append( + "\nThese identifiers contain invalid characters and must be normalized." + ) + error_lines.append( + "Update the CSV files in data/v2/csv/ to fix these identifiers." + ) + error_lines.append("\nSuggested fixes:") + error_lines.append( + " - Remove Unicode apostrophes (') and replace with regular hyphens or remove" + ) + error_lines.append(" - Remove Unicode letters (ñ → n)") + error_lines.append(" - Remove parentheses and other special characters") + error_lines.append(" - Convert to lowercase") + + self.fail("\n".join(error_lines)) + + def test_identifier_pattern_examples(self): + """Test that the validation pattern works correctly with example identifiers.""" + # Valid identifiers + valid_identifiers = [ + "pikachu", + "charizard-mega-x", + "mr-mime", + "ho-oh", + "type-null", + "item-123", + "mega-stone", + ] + + for identifier in valid_identifiers: + self.assertTrue( + self.VALID_IDENTIFIER_PATTERN.match(identifier), + f"{identifier} should be valid but was rejected", + ) + + # Invalid identifiers + invalid_identifiers = [ + "Pikachu", # Uppercase + "Mr. Mime", # Space and period + "kofu's-wallet", # Unicode apostrophe + "jalapeño", # Unicode ñ + "steel-bottle-(r)", # Parentheses + "b&w-grass-tablecloth", # Ampersand + "farfetch'd", # Apostrophe + "kofu's-wallet", # Regular apostrophe + ] + + for identifier in invalid_identifiers: + self.assertFalse( + self.VALID_IDENTIFIER_PATTERN.match(identifier), + f"{identifier} should be invalid but was accepted", + )