diff --git a/.github/workflows/cd-workflow.yml b/.github/workflows/cd-workflow.yml index f95b6c7..c57d4ed 100644 --- a/.github/workflows/cd-workflow.yml +++ b/.github/workflows/cd-workflow.yml @@ -6,7 +6,7 @@ on: - dev pull_request: branches: - - main + - dev types: [opened, synchronize, reopened] jobs: @@ -46,47 +46,4 @@ jobs: docker network create openlxp echo "Docker network successfully created" echo "Running coverage unit test" - docker-compose --env-file ./.env run app sh -c "python manage.py waitdb && coverage run manage.py test --tag=unit && flake8 && coverage report && coverage report --fail-under=80" - - sonarcloud: - name: SonarCloud - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - with: - fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - - name: SonarCloud Scan - uses: SonarSource/sonarcloud-github-action@master - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - build: - # require dependency from step above - needs: code-test - name: Build Docker Image - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v2 - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v1 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ${{ secrets.AWS_REGION }} - - name: Login to Amazon ECR - id: login-ecr - uses: aws-actions/amazon-ecr-login@v1 - with: - mask-password: 'true' - - name: Build, tag, and push image to Amazon ECR - env: - ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} - ECR_REPOSITORY: ${{ secrets.ECR_REPO }} - IMAGE_TAG: xss - run: | - echo "Starting docker build" - docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . - echo "Pushing image to ECR..." - docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + docker compose --env-file ./.env run app sh -c "python manage.py waitdb && coverage run manage.py test --tag=unit && flake8 && coverage report && coverage report --fail-under=80" diff --git a/Dockerfile b/Dockerfile index 1ed5519..91e38da 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # Dockerfile -FROM python:3.7-buster +FROM python:3.9-bookworm # install nginx RUN apt-get update && apt-get install nginx vim libxml2-dev libxmlsec1-dev clamav-daemon clamav-freshclam clamav-unofficial-sigs -y --no-install-recommends diff --git a/README.md b/README.md index d6cf518..0692c57 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ The Experience Schema Service (XSS) maintains referential representations of domain entities, as well as transformational mappings that describe how to convert an entity from one particular schema representation to another. -This component responsible for managing pertinent object/record metadata schemas, and the mappings for transforming records from a source metadata schema to a target metadata schema. This component will also be used to store and link vocabularies from stored schema. +This component is responsible for managing pertinent object/record metadata schemas, and the mappings for transforming records from a source metadata schema to a target metadata schema. This component will also be used to store and link vocabularies from stored schema. ## Prerequisites @@ -31,28 +31,31 @@ Or copy it into one of these folders to install it system-wide: ## 1. Clone the project Clone the Github repository ``` -git clone https://github.com/OpenLXP/openlxp-xss.git +git clone https://github.com/adlnet/ecc-openlxp-xss.git ``` ## 2. Set up your environment variables - Create a `.env` file in the root directory - The following environment variables are required: -| Environment Variable | Description | -| ------------------------- | ----------- | -| AWS_ACCESS_KEY_ID | The Access Key ID for AWS | -| AWS_SECRET_ACCESS_KEY | The Secret Access Key for AWS | -| AWS_DEFAULT_REGION | The region for AWS | -| DB_HOST | The host name, IP, or docker container name of the database | -| DB_NAME | The name to give the database | -| DB_PASSWORD | The password for the user to access the database | -| DB_ROOT_PASSWORD | The password for the root user to access the database, should be the same as `DB_PASSWORD` if using the root user | +| Environment Variable | Description | +| ------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| CORS_ALLOWED_ORIGINS | List of trusted origins that are allowed to make cross-origin requests to the server | +| CSRF_TRUSTED_ORIGINS | A trusted origin for unsafe requests | +| DB_HOST | The host name, IP, or docker container name of the database | +| DB_NAME | The name to give the database | +| DB_PASSWORD | The password for the user to access the database | +| DB_ROOT_PASSWORD | The password for the root user to access the database, should be the same as `DB_PASSWORD` if using the root user | | DB_USER | The name of the user to use when connecting to the database. When testing use root to allow the creation of a test database | -| DJANGO_SUPERUSER_EMAIL | The email of the superuser that will be created in the application | -| DJANGO_SUPERUSER_PASSWORD | The password of the superuser that will be created in the application | -| DJANGO_SUPERUSER_USERNAME | The username of the superuser that will be created in the application | -| LOG_PATH | The path to the log file to use | -| SECRET_KEY_VAL | The Secret Key for Django | +| DJANGO_SUPERUSER_EMAIL | The email of the superuser that will be created in the application | +| DJANGO_SUPERUSER_PASSWORD | The password of the superuser that will be created in the application | +| DJANGO_SUPERUSER_USERNAME | The username of the superuser that will be created in the application | +| ENTITY_ID | The Entity ID used to identify this application to Identity Providers when using Single Sign On | +| HOSTS | A list of host names, separated by semicolons, that the application should accept requests for | +| LOG_PATH | The path to the log file to use | +| SECRET_KEY_VAL | The Secret Key for Django | +| SP_PRIVATE_KEY | The Private Key to use when this application communicates with Identity Providers to use Single Sign On | +| SP_PUBLIC_CERT | The Public Key to use when this application communicates with Identity Providers to use Single Sign On | ## 3. Deployment 1. Create the OpenLXP docker network. Open a terminal and run the following command in the root directory of the project @@ -133,22 +136,39 @@ git clone https://github.com/OpenLXP/openlxp-xss.git - `Name` Term title - - `Desciption` Term entity's description + - `Description` Term entity's description - `Status` Select if the Term set is Published or Retired - `Data Type` Term entity's corresponding data type - `Use` Term entity's corresponding use case + + - `Multiple Expected` Whether the Term should be a singular value or a list - `Source` Term entity's corresponding source - - `term set` Select the reference to the parent term set from the drop down + - `Term Set` Select the reference to the parent term set from the drop down - `Mapping` Add mappings between terms entity's of different parent term set - `Updated by` User that creates/updates the term +3. [OPENLXP AUTHENTICATION](https://pypi.org/project/openlxp-authentication/) + - Saml configurations: Configure Security Assertion Markup Language (SAML) + 1. Click on `Saml configurations` > `Add Saml configuration` + - Enter configurations below: + + - `Name`: The name that will be used to identify the IdP in the URL. + + - `Entity id`: The unique name provided by the IdP. + + - `Url`: The connection URL to connect to the IdP at. + + - `Cert`: The public cert used to connect to the IdP. + + - `Attribute mapping`: The JSON formatted mapping to convert attributes provided by the IdP, to a User in this system. + ## 5. Removing Deployment To destroy the created resources, simply run the command below in your terminal: @@ -164,7 +184,7 @@ Query string parameter: `name` `version` `iri` -**Note:This API fetches the required schema from the repository using the Name and Version or IRI parameters** +*Note:This API fetches the required schema from the repository using the Name and Version or IRI parameters* Query string parameter: `sourceName` `sourceVersion` `sourceIRI` `targetName` `targetVersion` `targetIRI` @@ -183,7 +203,19 @@ Test coverage information will be stored in an htmlcov directory docker-compose --env-file .env run app sh -c "coverage run manage.py test && coverage html && flake8" ``` +## Authentication + +While XSS supports authentication and authorization, the schema and mapping APIs do not require authentication to use, as it is believed that they should be easily accessible shared resources. + +The Django settings `SP_PUBLIC_CERT`, `SP_PRIVATE_KEY` , and `SP_ENTITY_ID` must be defined (if using docker-compose the variables can be passed through). + +Information on the settings for the authentication module can be found on the [OpenLXP-Authentication repo](https://github.com/adlnet/openlxp-authentication). + +## Additional Info + +Additional information about ECC can be found in our [ECC wiki](https://github.com/adlnet/ecc-openlxp-xds-ui/wiki) + ## License - This project uses the [MIT](http://www.apache.org/licenses/LICENSE-2.0) license. + This project uses the [Apache](http://www.apache.org/licenses/LICENSE-2.0) license. diff --git a/app/core/admin.py b/app/core/admin.py index dd431c5..0ef7424 100644 --- a/app/core/admin.py +++ b/app/core/admin.py @@ -90,7 +90,9 @@ class TermAdmin(admin.ModelAdmin): 'modified', ) fieldsets = ( (None, {'fields': ('iri', 'name', 'uuid', 'description', 'status',)}), - ('Info', {'fields': ('data_type', 'use', 'source',)}), + ('Info', {'fields': ('data_type', 'use', + 'multiple_expected', + 'source',)}), ('Connections', {'fields': ('term_set', 'mapping',)}), ('Updated', {'fields': ('updated_by',), }) ) @@ -108,5 +110,5 @@ def get_form(self, request, obj=None, **kwargs): form = super(TermAdmin, self).get_form(request, obj, **kwargs) if obj is not None: form.base_fields['mapping'].queryset = Term.objects.exclude( - iri__startswith=obj.root_term_set()) + iri__istartswith=obj.root_term_set()) return form diff --git a/app/core/migrations/0007_alter_term_mapping.py b/app/core/migrations/0007_alter_term_mapping.py new file mode 100644 index 0000000..9ee8ef1 --- /dev/null +++ b/app/core/migrations/0007_alter_term_mapping.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-10-17 16:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0006_auto_20230901_1454'), + ] + + operations = [ + migrations.AlterField( + model_name='term', + name='mapping', + field=models.ManyToManyField(blank=True, to='core.term'), + ), + ] diff --git a/app/core/migrations/0008_term_multiple_expected_term_type.py b/app/core/migrations/0008_term_multiple_expected_term_type.py new file mode 100644 index 0000000..6978494 --- /dev/null +++ b/app/core/migrations/0008_term_multiple_expected_term_type.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.23 on 2025-07-14 17:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0007_alter_term_mapping'), + ] + + operations = [ + migrations.AddField( + model_name='term', + name='multiple_expected', + field=models.BooleanField(default=False, help_text='Whether multiple values are expected for this Term'), + ), + migrations.AddField( + model_name='term', + name='type', + field=models.CharField(choices=[('Learning Resource', 'Learning Resource'), ('Learning Event', 'Learning Event'), ('Both', 'Both')], default='', max_length=255), + preserve_default=False, + ), + ] diff --git a/app/core/migrations/0009_remove_term_type_term_learning_type_and_more.py b/app/core/migrations/0009_remove_term_type_term_learning_type_and_more.py new file mode 100644 index 0000000..b3aa642 --- /dev/null +++ b/app/core/migrations/0009_remove_term_type_term_learning_type_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 4.2.23 on 2025-07-14 17:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0008_term_multiple_expected_term_type'), + ] + + operations = [ + migrations.RemoveField( + model_name='term', + name='type', + ), + migrations.AddField( + model_name='term', + name='learning_type', + field=models.CharField(blank=True, choices=[('Learning Resource', 'Learning Resource'), ('Learning Event', 'Learning Event'), ('Both', 'Both')], default=None, max_length=255, null=True), + ), + migrations.AlterField( + model_name='term', + name='multiple_expected', + field=models.BooleanField(default=True, help_text='Whether multiple values are expected for this Term'), + ), + ] diff --git a/app/core/migrations/0010_remove_term_learning_type.py b/app/core/migrations/0010_remove_term_learning_type.py new file mode 100644 index 0000000..543dd38 --- /dev/null +++ b/app/core/migrations/0010_remove_term_learning_type.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.23 on 2025-08-07 16:15 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0009_remove_term_type_term_learning_type_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='term', + name='learning_type', + ), + ] diff --git a/app/core/models.py b/app/core/models.py index 28bede0..4e3965d 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -34,6 +34,10 @@ r'| \xF1-\xF3{3} # planes 4-15 ' r'| \xF4\x80-\x8F{2} # plane 16 )*\Z))') +source_tag = 'ldss:' +graph_tag = '@graph' +context_tag = '@context' + def validate_version(value): check = re.fullmatch('[0-9]*[.][0-9]*[.][0-9]*', value) @@ -79,33 +83,33 @@ def json_ld(self): graph = {} context = {} # add elements to graph and context - graph['@id'] = 'ldss:' + self.iri + graph['@id'] = source_tag + self.iri graph['@type'] = 'rdfs:Class' graph['rdfs:label'] = self.name context['rdfs'] = 'http://www.w3.org/2000/01/rdf-schema#' if hasattr(self, 'childtermset'): graph['schema:domainIncludes'] = { - '@id': 'ldss:' + + '@id': source_tag + self.childtermset.parent_term_set.iri} context['schema'] = 'https://schema.org/' # iterate over child term sets and collect their graphs and contexts children = [] for kid in self.children.filter(status='published'): kid_ld = kid.json_ld() - children.extend(kid_ld['@graph']) + children.extend(kid_ld[graph_tag]) # add children's context to current context, but current has # higher priority - context = {**kid_ld['@context'], **context} + context = {**kid_ld[context_tag], **context} # iterate over terms and collect their graphs and contexts terms = [] for term in self.terms.filter(status='published'): term_ld = term.json_ld() - terms.extend(term_ld['@graph']) + terms.extend(term_ld[graph_tag]) # add terms' context to current context, but current has higher # priority - context = {**term_ld['@context'], **context} + context = {**term_ld[context_tag], **context} # return the graph and context - return {'@context': context, '@graph': [graph, *children, *terms]} + return {context_tag: context, graph_tag: [graph, *children, *terms]} def mapped_to(self, target_root): """Return dict of Terms mapped to anything in target_root string""" @@ -149,6 +153,10 @@ class Term(TimeStampedModel): ('Optional', 'Optional'), ('Recommended', 'Recommended'), ] + TYPE_CHOICES = [('Learning Resource', 'Learning Resource'), + ('Learning Event', 'Learning Event'), + ('Both', 'Both'), + ] name = models.SlugField(max_length=255, allow_unicode=True) description = models.TextField(null=True, blank=True) iri = models.SlugField(max_length=255, unique=True, @@ -156,6 +164,11 @@ class Term(TimeStampedModel): uuid = models.UUIDField(default=uuid4, editable=False, unique=True) data_type = models.CharField(max_length=255, null=True, blank=True) use = models.CharField(max_length=255, choices=USE_CHOICES) + # multiple_expected fields added in migration 0008 + multiple_expected = models.BooleanField(default=True, + help_text="Whether multiple" + " values " + "are expected for this Term") source = models.CharField(max_length=255, null=True, blank=True) term_set = models.ForeignKey( TermSet, on_delete=models.CASCADE, related_name='terms') @@ -184,6 +197,7 @@ def export(self): """convert key attributes of the Term to a dict""" attrs = {} attrs['use'] = self.use + attrs['multiple_expected'] = self.multiple_expected if self.data_type is not None and self.data_type != '': attrs['data_type'] = self.data_type if self.source is not None and self.source != '': @@ -198,7 +212,7 @@ def json_ld(self): graph = {} context = {} # add elements to graph and context - graph['@id'] = 'ldss:' + self.iri + graph['@id'] = source_tag + self.iri graph['@type'] = 'rdf:Property' if self.description is not None and len(self.description.strip()) > 0: graph['rdfs:comment'] = self.description @@ -208,7 +222,7 @@ def json_ld(self): '@id': data_type_matching[self.data_type]} if self.mapping.exists(): graph['owl:equivalentProperty'] = [ - {'@id': 'ldss:' + alt.iri} for alt in self.mapping.all()] + {'@id': source_tag + alt.iri} for alt in self.mapping.all()] context['owl'] = 'http://www.w3.org/2002/07/owl#' graph['rdfs:label'] = self.name graph['schema:domainIncludes'] = {'@id': 'ldss:' + self.term_set.iri} @@ -216,7 +230,7 @@ def json_ld(self): context['rdfs'] = 'http://www.w3.org/2000/01/rdf-schema#' context['rdf'] = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#' # return the graph and context - return {'@context': context, '@graph': [graph, ]} + return {context_tag: context, graph_tag: [graph, ]} def path(self): """Get the path of the Term""" @@ -233,7 +247,7 @@ def path(self): def mapped_to(self, target_root): """Return path if Term is mapped to anything in target_root string""" - target_map = self.mapping.filter(iri__startswith=target_root) + target_map = self.mapping.filter(iri__istartswith=target_root) if target_map.exists(): return target_map.first().path() return None @@ -289,7 +303,8 @@ def clean(self): if self.schema_file: # scan file for malicious payloads - cd = clamd.ClamdUnixSocket() + cd = clamd.ClamdNetworkSocket(host="clamd.clamav", port=3310, + timeout=10) json_file = self.schema_file scan_results = cd.instream(json_file)['stream'] if 'OK' not in scan_results: @@ -386,13 +401,15 @@ def clean(self): if self.schema_mapping_file: json_file = self.schema_mapping_file # scan file for malicious payloads - cd = clamd.ClamdUnixSocket() + cd = clamd.ClamdNetworkSocket(host="clamd.clamav", + port=3310, timeout=10) scan_results = cd.instream(json_file)['stream'] if 'OK' not in scan_results: for issue_type, issue in [scan_results, ]: logger.error( '%s %s in transform %s to %s', - issue_type, issue, self.source_schema.iri, self.target_schema.iri # noqa: E501 + issue_type, issue, self.source_schema.iri, + self.target_schema.iri # noqa: E501 ) # only load json if no issues found else: diff --git a/app/core/tests/test_models_unit.py b/app/core/tests/test_models_unit.py index 848cd2d..2a5eec7 100644 --- a/app/core/tests/test_models_unit.py +++ b/app/core/tests/test_models_unit.py @@ -67,10 +67,11 @@ def test_schema_ledger_virus(self): patch_version=patch_version, schema_file=file) - with patch('core.models.logger') as log,\ + with patch('core.models.logger') as log, \ patch('core.models.clamd') as clam: clam.instream.return_value = {'stream': ('BAD', 'EICAR')} clam.ClamdUnixSocket.return_value = clam + clam.ClamdNetworkSocket.return_value = clam self.assertEqual(schema.version, '') self.assertEqual(schema.schema_file.size, len(EICAR)) @@ -104,14 +105,15 @@ def test_schema_ledger_non_json(self): patch_version=patch_version, schema_file=file) - with patch('core.models.logger') as log,\ - patch('core.models.clamd') as clam,\ - patch('builtins.open', mock_open()),\ - patch('core.models.magic') as magic,\ + with patch('core.models.logger') as log, \ + patch('core.models.clamd') as clam, \ + patch('builtins.open', mock_open()), \ + patch('core.models.magic') as magic, \ patch('core.models.os'): magic.from_file.return_value = 'text/plain' clam.instream.return_value = {'stream': ('OK', 'OKAY')} clam.ClamdUnixSocket.return_value = clam + clam.ClamdNetworkSocket.return_value = clam self.assertEqual(schema.version, '') self.assertEqual(schema.schema_file.size, len(file_contents)) @@ -127,7 +129,6 @@ def test_schema_ledger_bleach(self): """Test that creating a SchemaLedger with a valid file passes""" schema_name = 'test_name' - schema_iri = 'test_iri' status = 'published' version = '1.0.1' major_version = 1 @@ -139,21 +140,22 @@ def test_schema_ledger_bleach(self): metadata = {'test': 'test'} schema = SchemaLedger(schema_name=schema_name, - schema_iri=schema_iri, + schema_iri=schema_name, status=status, major_version=major_version, minor_version=minor_version, patch_version=patch_version, schema_file=file) - with patch('core.models.logger') as log,\ - patch('core.models.clamd') as clam,\ - patch('builtins.open', mock_open()),\ - patch('core.models.magic') as magic,\ + with patch('core.models.logger') as log, \ + patch('core.models.clamd') as clam, \ + patch('builtins.open', mock_open()), \ + patch('core.models.magic') as magic, \ patch('core.models.os'): magic.from_file.return_value = 'application/json' clam.instream.return_value = {'stream': ('OK', 'OKAY')} clam.ClamdUnixSocket.return_value = clam + clam.ClamdNetworkSocket.return_value = clam self.assertEqual(schema.version, '') self.assertEqual(schema.schema_file.size, len(file_contents)) @@ -204,10 +206,11 @@ def test_transformation_ledger_virus(self): schema_mapping_file=file, status=status) - with patch('core.models.logger') as log,\ + with patch('core.models.logger') as log, \ patch('core.models.clamd') as clam: clam.instream.return_value = {'stream': ('BAD', 'EICAR')} clam.ClamdUnixSocket.return_value = clam + clam.ClamdNetworkSocket.return_value = clam self.assertEqual(mapping.schema_mapping_file.size, len(EICAR)) mapping.clean() @@ -235,14 +238,15 @@ def test_transformation_ledger_non_json(self): schema_mapping_file=file, status=status) - with patch('core.models.logger') as log,\ - patch('core.models.clamd') as clam,\ - patch('builtins.open', mock_open()),\ - patch('core.models.magic') as magic,\ + with patch('core.models.logger') as log, \ + patch('core.models.clamd') as clam, \ + patch('builtins.open', mock_open()), \ + patch('core.models.magic') as magic, \ patch('core.models.os'): magic.from_file.return_value = 'text/plain' clam.instream.return_value = {'stream': ('OK', 'OKAY')} clam.ClamdUnixSocket.return_value = clam + clam.ClamdNetworkSocket.return_value = clam self.assertEqual(mapping.schema_mapping_file.size, len(file_contents)) @@ -272,14 +276,15 @@ def test_transformation_ledger_bleach(self): schema_mapping_file=file, status=status) - with patch('core.models.logger') as log,\ - patch('core.models.clamd') as clam,\ - patch('builtins.open', mock_open()),\ - patch('core.models.magic') as magic,\ + with patch('core.models.logger') as log, \ + patch('core.models.clamd') as clam, \ + patch('builtins.open', mock_open()), \ + patch('core.models.magic') as magic, \ patch('core.models.os'): magic.from_file.return_value = 'application/json' clam.instream.return_value = {'stream': ('OK', 'OKAY')} clam.ClamdUnixSocket.return_value = clam + clam.ClamdNetworkSocket.return_value = clam self.assertEqual(mapping.schema_mapping_file.size, len(file_contents)) @@ -330,17 +335,21 @@ def test_term(self): t_description = "test description" t_data_type = "string" t_use = Term.USE_CHOICES[0][0] + t_multiple_expected = False t_source = "source" t_ts = self.ts t_status = "published" expected_iri = "xss:" + t_ts.version + "@" + t_ts.name + "?" + t_name expected_export = {'use': t_use, 'data_type': t_data_type, - 'source': t_source, 'description': t_description} + 'source': t_source, + 'description': t_description, + 'multiple_expected': t_multiple_expected} term = Term(name=t_name, description=t_description, data_type=t_data_type, use=t_use, - source=t_source, term_set=t_ts, status=t_status) + source=t_source, term_set=t_ts, status=t_status, + multiple_expected=t_multiple_expected) term.save() diff --git a/app/openlxp_xss_project/settings.py b/app/openlxp_xss_project/settings.py index a318819..1ee6c3c 100644 --- a/app/openlxp_xss_project/settings.py +++ b/app/openlxp_xss_project/settings.py @@ -26,11 +26,23 @@ SECRET_KEY = os.environ.get('SECRET_KEY_VAL') # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = False mimetypes.add_type("text/css", ".css", True) -ALLOWED_HOSTS = ['*'] +ALLOWED_HOSTS = os.environ.get('HOSTS').split(';') + +# Content Security Policy (CSP) +SELF_VALUE = "'self'" # defining a constant +IMG_DATA_VALUE = "data:" + +CSP_DEFAULT_SRC = (SELF_VALUE) +CSP_SCRIPT_SRC = (SELF_VALUE,) +CSP_IMG_SRC = (SELF_VALUE, IMG_DATA_VALUE) +CSP_STYLE_SRC = (SELF_VALUE) +CSP_FRAME_SRC = (SELF_VALUE,) +CSP_FONT_SRC = (SELF_VALUE,) + # Application definition @@ -43,11 +55,13 @@ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'django.contrib.admindocs', 'corsheaders', 'rest_framework', 'rest_framework.authtoken', 'core.apps.CoreConfig', 'api', + 'health_check', 'users', 'social_django', 'openlxp_authentication', @@ -61,8 +75,20 @@ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'django.contrib.admindocs.middleware.XViewMiddleware', + 'csp.middleware.CSPMiddleware', ] + +# CORS_ALLOWED_ORIGINS = [os.environ.get('CORS_ALLOWED_ORIGINS')] +# CORS_ALLOW_CREDENTIALS = True + +SESSION_COOKIE_SECURE = True +SECURE_BROWSER_XSS_FILTER = True +SECURE_HSTS_SECONDS = 31536000 +SECURE_HSTS_INCLUDE_SUBDOMAINS = True + + ROOT_URLCONF = 'openlxp_xss_project.urls' TEMPLATES = [ @@ -185,6 +211,15 @@ ], } +CSRF_COOKIE_HTTPONLY = True +CSRF_COOKIE_SECURE = True +if os.environ.get('CSRF_TRUSTED_ORIGINS'): + CSRF_TRUSTED_ORIGINS = os.environ.get('CSRF_TRUSTED_ORIGINS').split(';') +else: + CSRF_TRUSTED_ORIGINS = ['http://localhost:8010'] + +# SECURE_SSL_REDIRECT = True + AUTHENTICATION_BACKENDS = ( 'django.contrib.auth.backends.ModelBackend', 'openlxp_authentication.models.SAMLDBAuth', diff --git a/app/openlxp_xss_project/urls.py b/app/openlxp_xss_project/urls.py index acc47ef..9777799 100644 --- a/app/openlxp_xss_project/urls.py +++ b/app/openlxp_xss_project/urls.py @@ -14,14 +14,16 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.conf import settings -from django.conf.urls import url from django.conf.urls.static import static from django.contrib import admin -from django.urls import include, path +from django.urls import include, re_path urlpatterns = [ - url('', include('openlxp_authentication.urls')), - path('admin/', admin.site.urls), - path('api/', include('api.urls')), - path('api/auth/', include('users.urls')), + re_path('admin/doc/', include('django.contrib.admindocs.urls')), + # url('', include('openlxp_authentication.urls')), + re_path('admin/', admin.site.urls), + re_path('api/', include('api.urls')), + re_path('api/auth/', include('users.urls')), + re_path('health/', include('health_check.urls'), + name='health_check') ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/app/static/admin/css/base.css b/app/static/admin/css/base.css index 1cb3acd..93db7d0 100644 --- a/app/static/admin/css/base.css +++ b/app/static/admin/css/base.css @@ -2,93 +2,90 @@ DJANGO Admin styles */ -@import url(fonts.css); - /* VARIABLE DEFINITIONS */ +html[data-theme="light"], :root { - --primary: #79aec8; - --secondary: #417690; - --accent: #f5dd5d; - --primary-fg: #fff; - - --body-fg: #333; - --body-bg: #fff; - --body-quiet-color: #666; - --body-loud-color: #000; - - --header-color: #ffc; - --header-branding-color: var(--accent); - --header-bg: var(--secondary); - --header-link-color: var(--primary-fg); - - --breadcrumbs-fg: #c4dce8; - --breadcrumbs-link-fg: var(--body-bg); - --breadcrumbs-bg: var(--primary); - - --link-fg: #447e9b; - --link-hover-color: #036; - --link-selected-fg: #5b80b2; - - --hairline-color: #e8e8e8; - --border-color: #ccc; - - --error-fg: #ba2121; - - --message-success-bg: #dfd; - --message-warning-bg: #ffc; - --message-error-bg: #ffefef; - - --darkened-bg: #f8f8f8; /* A bit darker than --body-bg */ - --selected-bg: #e4e4e4; /* E.g. selected table cells */ - --selected-row: #ffc; - - --button-fg: #fff; - --button-bg: var(--primary); - --button-hover-bg: #609ab6; - --default-button-bg: var(--secondary); - --default-button-hover-bg: #205067; - --close-button-bg: #888; /* Previously #bbb, contrast 1.92 */ - --close-button-hover-bg: #747474; - --delete-button-bg: #ba2121; - --delete-button-hover-bg: #a41515; - - --object-tools-fg: var(--button-fg); - --object-tools-bg: var(--close-button-bg); - --object-tools-hover-bg: var(--close-button-hover-bg); -} - -@media (prefers-color-scheme: dark) { - :root { - --primary: #264b5d; - --primary-fg: #eee; - - --body-fg: #eeeeee; - --body-bg: #121212; - --body-quiet-color: #e0e0e0; - --body-loud-color: #ffffff; - - --breadcrumbs-link-fg: #e0e0e0; + --primary: #79aec8; + --secondary: #417690; + --accent: #f5dd5d; + --primary-fg: #fff; + + --body-fg: #333; + --body-bg: #fff; + --body-quiet-color: #666; + --body-loud-color: #000; + + --header-color: #ffc; + --header-branding-color: var(--accent); + --header-bg: var(--secondary); + --header-link-color: var(--primary-fg); + + --breadcrumbs-fg: #c4dce8; + --breadcrumbs-link-fg: var(--body-bg); --breadcrumbs-bg: var(--primary); - --link-fg: #81d4fa; - --link-hover-color: #4ac1f7; - --link-selected-fg: #6f94c6; - - --hairline-color: #272727; - --border-color: #353535; - - --error-fg: #e35f5f; - --message-success-bg: #006b1b; - --message-warning-bg: #583305; - --message-error-bg: #570808; - - --darkened-bg: #212121; - --selected-bg: #1b1b1b; - --selected-row: #00363a; - - --close-button-bg: #333333; - --close-button-hover-bg: #666666; - } + --link-fg: #417893; + --link-hover-color: #036; + --link-selected-fg: #5b80b2; + + --hairline-color: #e8e8e8; + --border-color: #ccc; + + --error-fg: #ba2121; + + --message-success-bg: #dfd; + --message-warning-bg: #ffc; + --message-error-bg: #ffefef; + + --darkened-bg: #f8f8f8; /* A bit darker than --body-bg */ + --selected-bg: #e4e4e4; /* E.g. selected table cells */ + --selected-row: #ffc; + + --button-fg: #fff; + --button-bg: var(--primary); + --button-hover-bg: #609ab6; + --default-button-bg: var(--secondary); + --default-button-hover-bg: #205067; + --close-button-bg: #747474; + --close-button-hover-bg: #333; + --delete-button-bg: #ba2121; + --delete-button-hover-bg: #a41515; + + --object-tools-fg: var(--button-fg); + --object-tools-bg: var(--close-button-bg); + --object-tools-hover-bg: var(--close-button-hover-bg); + + --font-family-primary: + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + system-ui, + Roboto, + "Helvetica Neue", + Arial, + sans-serif, + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji"; + --font-family-monospace: + ui-monospace, + Menlo, + Monaco, + "Cascadia Mono", + "Segoe UI Mono", + "Roboto Mono", + "Oxygen Mono", + "Ubuntu Monospace", + "Source Code Pro", + "Fira Mono", + "Droid Sans Mono", + "Courier New", + monospace, + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji"; } html, body { @@ -98,8 +95,8 @@ html, body { body { margin: 0; padding: 0; - font-size: 14px; - font-family: "Roboto","Lucida Grande","DejaVu Sans","Bitstream Vera Sans",Verdana,Arial,sans-serif; + font-size: 0.875rem; + font-family: var(--font-family-primary); color: var(--body-fg); background: var(--body-bg); } @@ -151,12 +148,12 @@ h1,h2,h3,h4,h5 { h1 { margin: 0 0 20px; font-weight: 300; - font-size: 20px; + font-size: 1.25rem; color: var(--body-quiet-color); } h2 { - font-size: 16px; + font-size: 1rem; margin: 1em 0 .5em 0; } @@ -166,20 +163,20 @@ h2.subhead { } h3 { - font-size: 14px; + font-size: 0.875rem; margin: .8em 0 .3em 0; color: var(--body-quiet-color); font-weight: bold; } h4 { - font-size: 12px; + font-size: 0.75rem; margin: 1em 0 .8em 0; padding-bottom: 3px; } h5 { - font-size: 10px; + font-size: 0.625rem; margin: 1.5em 0 .5em 0; color: var(--body-quiet-color); text-transform: uppercase; @@ -196,8 +193,8 @@ li ul { } li, dt, dd { - font-size: 13px; - line-height: 20px; + font-size: 0.8125rem; + line-height: 1.25rem; } dt { @@ -223,7 +220,7 @@ fieldset { } blockquote { - font-size: 11px; + font-size: 0.6875rem; color: #777; margin-left: 2px; padding-left: 10px; @@ -231,9 +228,9 @@ blockquote { } code, pre { - font-family: "Bitstream Vera Sans Mono", Monaco, "Courier New", Courier, monospace; + font-family: var(--font-family-monospace); color: var(--body-quiet-color); - font-size: 12px; + font-size: 0.75rem; overflow-x: auto; } @@ -255,22 +252,21 @@ hr { border: none; margin: 0; padding: 0; - font-size: 1px; line-height: 1px; } /* TEXT STYLES & MODIFIERS */ .small { - font-size: 11px; + font-size: 0.6875rem; } .mini { - font-size: 10px; + font-size: 0.625rem; } .help, p.help, form p.help, div.help, form div.help, div.help li { - font-size: 11px; + font-size: 0.6875rem; color: var(--body-quiet-color); } @@ -300,7 +296,7 @@ p img, h1 img, h2 img, h3 img, h4 img, td img { } .hidden { - display: none; + display: none !important; } /* TABLES */ @@ -311,8 +307,8 @@ table { } td, th { - font-size: 13px; - line-height: 16px; + font-size: 0.8125rem; + line-height: 1rem; border-bottom: 1px solid var(--hairline-color); vertical-align: top; padding: 8px; @@ -327,7 +323,7 @@ thead th, tfoot td { color: var(--body-quiet-color); padding: 5px 10px; - font-size: 11px; + font-size: 0.6875rem; background: var(--body-bg); border: none; border-top: 1px solid var(--hairline-color); @@ -437,7 +433,7 @@ table thead th.sorted .sortoptions a.sortremove:after { top: -6px; left: 3px; font-weight: 200; - font-size: 18px; + font-size: 1.125rem; color: var(--body-quiet-color); } @@ -476,9 +472,9 @@ input, textarea, select, .form-row p, form .button { margin: 2px 0; padding: 2px 3px; vertical-align: middle; - font-family: "Roboto", "Lucida Grande", Verdana, Arial, sans-serif; + font-family: var(--font-family-primary); font-weight: normal; - font-size: 13px; + font-size: 0.8125rem; } .form-row div.help { padding: 2px 3px; @@ -505,7 +501,7 @@ textarea:focus, select:focus, .vTextField:focus { } select { - height: 30px; + height: 1.875rem; } select[multiple] { @@ -541,7 +537,6 @@ a.button { } .button.default, input[type=submit].default, .submit-row input.default { - float: right; border: none; font-weight: 400; background: var(--default-button-bg); @@ -589,7 +584,7 @@ input[type=button][disabled].default { margin: 0; padding: 8px; font-weight: 400; - font-size: 13px; + font-size: 0.8125rem; text-align: left; background: var(--primary); color: var(--header-link-color); @@ -597,7 +592,7 @@ input[type=button][disabled].default { .module caption, .inline-group h2 { - font-size: 12px; + font-size: 0.75rem; letter-spacing: 0.5px; text-transform: uppercase; } @@ -616,12 +611,13 @@ ul.messagelist { ul.messagelist li { display: block; font-weight: 400; - font-size: 13px; + font-size: 0.8125rem; padding: 10px 10px 10px 65px; margin: 0 0 10px 0; background: var(--message-success-bg) url(../img/icon-yes.svg) 40px 12px no-repeat; background-size: 16px auto; color: var(--body-fg); + word-break: break-word; } ul.messagelist li.warning { @@ -635,7 +631,7 @@ ul.messagelist li.error { } .errornote { - font-size: 14px; + font-size: 0.875rem; font-weight: 700; display: block; padding: 10px 12px; @@ -656,7 +652,7 @@ ul.errorlist { } ul.errorlist li { - font-size: 13px; + font-size: 0.8125rem; display: block; margin-bottom: 4px; overflow-wrap: break-word; @@ -697,7 +693,7 @@ td ul.errorlist + input, td ul.errorlist + select, td ul.errorlist + textarea { } .description { - font-size: 12px; + font-size: 0.75rem; padding: 5px 0 0 12px; } @@ -753,7 +749,7 @@ a.deletelink:focus, a.deletelink:hover { /* OBJECT TOOLS */ .object-tools { - font-size: 10px; + font-size: 0.625rem; font-weight: bold; padding-left: 0; float: right; @@ -765,7 +761,7 @@ a.deletelink:focus, a.deletelink:hover { display: block; float: left; margin-left: 5px; - height: 16px; + height: 1rem; } .object-tools a { @@ -779,7 +775,7 @@ a.deletelink:focus, a.deletelink:hover { background: var(--object-tools-bg); color: var(--object-tools-fg); font-weight: 400; - font-size: 11px; + font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.5px; } @@ -808,14 +804,21 @@ a.deletelink:focus, a.deletelink:hover { /* OBJECT HISTORY */ -table#change-history { +#change-history table { width: 100%; } -table#change-history tbody th { +#change-history table tbody th { width: 16em; } +#change-history .paginator { + color: var(--body-quiet-color); + border-bottom: 1px solid var(--hairline-color); + background: var(--body-bg); + overflow: hidden; +} + /* PAGE STRUCTURE */ #container { @@ -842,6 +845,20 @@ table#change-history tbody th { max-width: 100%; } +.skip-to-content-link { + position: absolute; + top: -999px; + margin: 5px; + padding: 5px; + background: var(--body-bg); + z-index: 1; +} + +.skip-to-content-link:focus { + left: 0px; + top: 0px; +} + #content { padding: 20px 40px; } @@ -905,7 +922,7 @@ table#change-history tbody th { overflow: hidden; } -#header a:link, #header a:visited { +#header a:link, #header a:visited, #logout-form button { color: var(--header-link-color); } @@ -914,24 +931,25 @@ table#change-history tbody th { } #branding { - float: left; + display: flex; } #branding h1 { padding: 0; - margin: 0 20px 0 0; + margin: 0; + margin-inline-end: 20px; font-weight: 300; - font-size: 24px; - color: var(--accent); + font-size: 1.5rem; + color: var(--header-branding-color); } -#branding h1, #branding h1 a:link, #branding h1 a:visited { +#branding h1 a:link, #branding h1 a:visited { color: var(--accent); } #branding h2 { padding: 0 10px; - font-size: 14px; + font-size: 0.875rem; margin: -8px 0 8px 0; font-weight: normal; color: var(--header-color); @@ -941,25 +959,43 @@ table#change-history tbody th { text-decoration: none; } +#logout-form { + display: inline; +} + +#logout-form button { + background: none; + border: 0; + cursor: pointer; + font-family: var(--font-family-primary); +} + #user-tools { float: right; - padding: 0; margin: 0 0 0 20px; + text-align: right; +} + +#user-tools, #logout-form button{ + padding: 0; font-weight: 300; - font-size: 11px; + font-size: 0.6875rem; letter-spacing: 0.5px; text-transform: uppercase; - text-align: right; } -#user-tools a { +#user-tools a, #logout-form button { border-bottom: 1px solid rgba(255, 255, 255, 0.25); } -#user-tools a:focus, #user-tools a:hover { +#user-tools a:focus, #user-tools a:hover, +#logout-form button:active, #logout-form button:hover { text-decoration: none; - border-bottom-color: var(--primary); - color: var(--primary); + border-bottom: 0; +} + +#logout-form button:active, #logout-form button:hover { + margin-bottom: 1px; } /* SIDEBAR */ @@ -979,7 +1015,7 @@ table#change-history tbody th { } #content-related h4 { - font-size: 13px; + font-size: 0.8125rem; } #content-related p { @@ -1003,7 +1039,7 @@ table#change-history tbody th { padding: 16px; margin-bottom: 16px; border-bottom: 1px solid var(--hairline-color); - font-size: 18px; + font-size: 1.125rem; color: var(--body-fg); } @@ -1023,8 +1059,8 @@ table#change-history tbody th { .delete-confirmation form .cancel-link { display: inline-block; vertical-align: middle; - height: 15px; - line-height: 15px; + height: 0.9375rem; + line-height: 0.9375rem; border-radius: 4px; padding: 10px 15px; color: var(--button-fg); @@ -1050,3 +1086,60 @@ table#change-history tbody th { .popup #header { padding: 10px 20px; } + +/* PAGINATOR */ + +.paginator { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.8125rem; + padding-top: 10px; + padding-bottom: 10px; + line-height: 22px; + margin: 0; + border-top: 1px solid var(--hairline-color); + width: 100%; +} + +.paginator a:link, .paginator a:visited { + padding: 2px 6px; + background: var(--button-bg); + text-decoration: none; + color: var(--button-fg); +} + +.paginator a.showall { + border: none; + background: none; + color: var(--link-fg); +} + +.paginator a.showall:focus, .paginator a.showall:hover { + background: none; + color: var(--link-hover-color); +} + +.paginator .end { + margin-right: 6px; +} + +.paginator .this-page { + padding: 2px 6px; + font-weight: bold; + font-size: 0.8125rem; + vertical-align: top; +} + +.paginator a:focus, .paginator a:hover { + color: white; + background: var(--link-hover-color); +} + +.paginator input { + margin-left: auto; +} + +.base-svgs { + display: none; +} diff --git a/app/static/admin/css/changelists.css b/app/static/admin/css/changelists.css index b4a1557..a754513 100644 --- a/app/static/admin/css/changelists.css +++ b/app/static/admin/css/changelists.css @@ -84,18 +84,18 @@ #toolbar form input { border-radius: 4px; - font-size: 14px; + font-size: 0.875rem; padding: 5px; color: var(--body-fg); } #toolbar #searchbar { - height: 19px; + height: 1.1875rem; border: 1px solid var(--border-color); padding: 2px 5px; margin: 0; vertical-align: top; - font-size: 13px; + font-size: 0.8125rem; max-width: 100%; } @@ -105,7 +105,7 @@ #toolbar form input[type="submit"] { border: 1px solid var(--border-color); - font-size: 13px; + font-size: 0.8125rem; padding: 4px 8px; margin: 0; vertical-align: middle; @@ -125,6 +125,10 @@ margin-right: 4px; } +#changelist-search .help { + word-break: break-word; +} + /* FILTER COLUMN */ #changelist-filter { @@ -136,7 +140,7 @@ } #changelist-filter h2 { - font-size: 14px; + font-size: 0.875rem; text-transform: uppercase; letter-spacing: 0.5px; padding: 5px 15px; @@ -144,12 +148,35 @@ border-bottom: none; } -#changelist-filter h3 { +#changelist-filter h3, +#changelist-filter details summary { font-weight: 400; padding: 0 15px; margin-bottom: 10px; } +#changelist-filter details summary > * { + display: inline; +} + +#changelist-filter details > summary { + list-style-type: none; +} + +#changelist-filter details > summary::-webkit-details-marker { + display: none; +} + +#changelist-filter details > summary::before { + content: '→'; + font-weight: bold; + color: var(--link-hover-color); +} + +#changelist-filter details[open] > summary::before { + content: '↓'; +} + #changelist-filter ul { margin: 5px 0; padding: 0 15px 15px; @@ -169,8 +196,7 @@ #changelist-filter a { display: block; color: var(--body-quiet-color); - text-overflow: ellipsis; - overflow-x: hidden; + word-break: break-word; } #changelist-filter li.selected { @@ -190,83 +216,34 @@ } #changelist-filter #changelist-filter-clear a { - font-size: 13px; + font-size: 0.8125rem; padding-bottom: 10px; border-bottom: 1px solid var(--hairline-color); } /* DATE DRILLDOWN */ -.change-list ul.toplinks { - display: block; - float: left; - padding: 0; - margin: 0; - width: 100%; -} - -.change-list ul.toplinks li { - padding: 3px 6px; +.change-list .toplinks { + display: flex; + padding-bottom: 5px; + flex-wrap: wrap; + gap: 3px 17px; font-weight: bold; - list-style-type: none; - display: inline-block; -} - -.change-list ul.toplinks .date-back a { - color: var(--body-quiet-color); } -.change-list ul.toplinks .date-back a:focus, -.change-list ul.toplinks .date-back a:hover { - color: var(--link-hover-color); +.change-list .toplinks a { + font-size: 0.8125rem; } -/* PAGINATOR */ - -.paginator { - font-size: 13px; - padding-top: 10px; - padding-bottom: 10px; - line-height: 22px; - margin: 0; - border-top: 1px solid var(--hairline-color); - width: 100%; -} - -.paginator a:link, .paginator a:visited { - padding: 2px 6px; - background: var(--button-bg); - text-decoration: none; - color: var(--button-fg); -} - -.paginator a.showall { - border: none; - background: none; - color: var(--link-fg); +.change-list .toplinks .date-back { + color: var(--body-quiet-color); } -.paginator a.showall:focus, .paginator a.showall:hover { - background: none; +.change-list .toplinks .date-back:focus, +.change-list .toplinks .date-back:hover { color: var(--link-hover-color); } -.paginator .end { - margin-right: 6px; -} - -.paginator .this-page { - padding: 2px 6px; - font-weight: bold; - font-size: 13px; - vertical-align: top; -} - -.paginator a:focus, .paginator a:hover { - color: white; - background: var(--link-hover-color); -} - /* ACTIONS */ .filtered .actions { @@ -278,7 +255,13 @@ vertical-align: baseline; } -#changelist table tbody tr.selected { +/* Once the :has() pseudo-class is supported by all browsers, the tr.selected + selector and the JS adding the class can be removed. */ +#changelist tbody tr.selected { + background-color: var(--selected-row); +} + +#changelist tbody tr:has(.action-select:checked) { background-color: var(--selected-row); } @@ -287,22 +270,16 @@ background: var(--body-bg); border-top: none; border-bottom: none; - line-height: 24px; + line-height: 1.5rem; color: var(--body-quiet-color); width: 100%; } -#changelist .actions.selected { /* XXX Probably unused? */ - background: var(--body-bg); - border-top: 1px solid var(--body-bg); - border-bottom: 1px solid #edecd6; -} - #changelist .actions span.all, #changelist .actions span.action-counter, #changelist .actions span.clear, #changelist .actions span.question { - font-size: 13px; + font-size: 0.8125rem; margin: 0 0.5em; } @@ -312,11 +289,11 @@ #changelist .actions select { vertical-align: top; - height: 24px; + height: 1.5rem; color: var(--body-fg); border: 1px solid var(--border-color); border-radius: 4px; - font-size: 14px; + font-size: 0.875rem; padding: 0 0 0 4px; margin: 0; margin-left: 10px; @@ -329,17 +306,17 @@ #changelist .actions label { display: inline-block; vertical-align: middle; - font-size: 13px; + font-size: 0.8125rem; } #changelist .actions .button { - font-size: 13px; + font-size: 0.8125rem; border: 1px solid var(--border-color); border-radius: 4px; background: var(--body-bg); box-shadow: 0 -15px 20px -10px rgba(0, 0, 0, 0.15) inset; cursor: pointer; - height: 24px; + height: 1.5rem; line-height: 1; padding: 4px 8px; margin: 0; diff --git a/app/static/admin/css/dark_mode.css b/app/static/admin/css/dark_mode.css new file mode 100644 index 0000000..6d08233 --- /dev/null +++ b/app/static/admin/css/dark_mode.css @@ -0,0 +1,137 @@ +@media (prefers-color-scheme: dark) { + :root { + --primary: #264b5d; + --primary-fg: #f7f7f7; + + --body-fg: #eeeeee; + --body-bg: #121212; + --body-quiet-color: #e0e0e0; + --body-loud-color: #ffffff; + + --breadcrumbs-link-fg: #e0e0e0; + --breadcrumbs-bg: var(--primary); + + --link-fg: #81d4fa; + --link-hover-color: #4ac1f7; + --link-selected-fg: #6f94c6; + + --hairline-color: #272727; + --border-color: #353535; + + --error-fg: #e35f5f; + --message-success-bg: #006b1b; + --message-warning-bg: #583305; + --message-error-bg: #570808; + + --darkened-bg: #212121; + --selected-bg: #1b1b1b; + --selected-row: #00363a; + + --close-button-bg: #333333; + --close-button-hover-bg: #666666; + } + } + + +html[data-theme="dark"] { + --primary: #264b5d; + --primary-fg: #f7f7f7; + + --body-fg: #eeeeee; + --body-bg: #121212; + --body-quiet-color: #e0e0e0; + --body-loud-color: #ffffff; + + --breadcrumbs-link-fg: #e0e0e0; + --breadcrumbs-bg: var(--primary); + + --link-fg: #81d4fa; + --link-hover-color: #4ac1f7; + --link-selected-fg: #6f94c6; + + --hairline-color: #272727; + --border-color: #353535; + + --error-fg: #e35f5f; + --message-success-bg: #006b1b; + --message-warning-bg: #583305; + --message-error-bg: #570808; + + --darkened-bg: #212121; + --selected-bg: #1b1b1b; + --selected-row: #00363a; + + --close-button-bg: #333333; + --close-button-hover-bg: #666666; +} + +/* THEME SWITCH */ +.theme-toggle { + cursor: pointer; + border: none; + padding: 0; + background: transparent; + vertical-align: middle; + margin-inline-start: 5px; + margin-top: -1px; +} + +.theme-toggle svg { + vertical-align: middle; + height: 1rem; + width: 1rem; + display: none; +} + +/* +Fully hide screen reader text so we only show the one matching the current +theme. +*/ +.theme-toggle .visually-hidden { + display: none; +} + +html[data-theme="auto"] .theme-toggle .theme-label-when-auto { + display: block; +} + +html[data-theme="dark"] .theme-toggle .theme-label-when-dark { + display: block; +} + +html[data-theme="light"] .theme-toggle .theme-label-when-light { + display: block; +} + +/* ICONS */ +.theme-toggle svg.theme-icon-when-auto, +.theme-toggle svg.theme-icon-when-dark, +.theme-toggle svg.theme-icon-when-light { + fill: var(--header-link-color); + color: var(--header-bg); +} + +html[data-theme="auto"] .theme-toggle svg.theme-icon-when-auto { + display: block; +} + +html[data-theme="dark"] .theme-toggle svg.theme-icon-when-dark { + display: block; +} + +html[data-theme="light"] .theme-toggle svg.theme-icon-when-light { + display: block; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0,0,0,0); + white-space: nowrap; + border: 0; + color: var(--body-fg); + background-color: var(--body-bg); +} diff --git a/app/static/admin/css/dashboard.css b/app/static/admin/css/dashboard.css index 91d6efd..242b81a 100644 --- a/app/static/admin/css/dashboard.css +++ b/app/static/admin/css/dashboard.css @@ -1,4 +1,7 @@ /* DASHBOARD */ +.dashboard td, .dashboard th { + word-break: break-word; +} .dashboard .module table th { width: 100%; diff --git a/app/static/admin/css/forms.css b/app/static/admin/css/forms.css index 89b2270..9a8dad0 100644 --- a/app/static/admin/css/forms.css +++ b/app/static/admin/css/forms.css @@ -5,7 +5,7 @@ .form-row { overflow: hidden; padding: 10px; - font-size: 13px; + font-size: 0.8125rem; border-bottom: 1px solid var(--hairline-color); } @@ -22,12 +22,24 @@ form .form-row p { padding-left: 0; } +.flex-container { + display: flex; +} + +.form-multiline { + flex-wrap: wrap; +} + +.form-multiline > div { + padding-bottom: 10px; +} + /* FORM LABELS */ label { font-weight: normal; color: var(--body-quiet-color); - font-size: 13px; + font-size: 0.8125rem; } .required label, label.required { @@ -37,16 +49,19 @@ label { /* RADIO BUTTONS */ -form ul.radiolist li { - list-style-type: none; +form div.radiolist div { + padding-right: 7px; } -form ul.radiolist label { - float: none; - display: inline; +form div.radiolist.inline div { + display: inline-block; } -form ul.radiolist input[type="radio"] { +form div.radiolist label { + width: auto; +} + +form div.radiolist input[type="radio"] { margin: -2px 4px 0 0; padding: 0; } @@ -66,7 +81,7 @@ form ul.inline li { .aligned label { display: block; padding: 4px 10px 0 0; - float: left; + min-width: 160px; width: 160px; word-wrap: break-word; line-height: 1; @@ -76,14 +91,15 @@ form ul.inline li { content: ''; display: inline-block; vertical-align: middle; - height: 26px; + height: 1.625rem; } -.aligned label + p, .aligned label + div.help, .aligned label + div.readonly { +.aligned label + p, .aligned .checkbox-row + div.help, .aligned label + div.readonly { padding: 6px 0; margin-top: 0; margin-bottom: 0; - margin-left: 170px; + margin-left: 0; + overflow-wrap: break-word; } .aligned ul label { @@ -105,7 +121,7 @@ form .aligned ul { padding-left: 10px; } -form .aligned ul.radiolist { +form .aligned div.radiolist { display: inline-block; margin: 0; padding: 0; @@ -113,16 +129,17 @@ form .aligned ul.radiolist { form .aligned p.help, form .aligned div.help { - clear: left; margin-top: 0; margin-left: 160px; padding-left: 10px; } -form .aligned label + p.help, -form .aligned label + div.help { +form .aligned p.date div.help.timezonewarning, +form .aligned p.datetime div.help.timezonewarning, +form .aligned p.time div.help.timezonewarning { margin-left: 0; padding-left: 0; + font-weight: normal; } form .aligned p.help:last-child, @@ -167,14 +184,7 @@ form .aligned table p { width: 610px; } -.checkbox-row p.help, -.checkbox-row div.help { - margin-left: 0; - padding-left: 0; -} - fieldset .fieldBox { - float: left; margin-right: 20px; } @@ -185,6 +195,7 @@ fieldset .fieldBox { } form .wide p, +form .wide ul.errorlist, form .wide input + p.help, form .wide input + div.help { margin-left: 200px; @@ -192,7 +203,7 @@ form .wide input + div.help { form .wide p.help, form .wide div.help { - padding-left: 38px; + padding-left: 50px; } form div.help ul { @@ -238,19 +249,21 @@ fieldset.collapsed .collapse-toggle { /* MONOSPACE TEXTAREAS */ fieldset.monospace textarea { - font-family: "Bitstream Vera Sans Mono", Monaco, "Courier New", Courier, monospace; + font-family: var(--font-family-monospace); } /* SUBMIT ROW */ .submit-row { - padding: 12px 14px; + padding: 12px 14px 12px; margin: 0 0 20px; background: var(--darkened-bg); border: 1px solid var(--hairline-color); border-radius: 4px; - text-align: right; overflow: hidden; + display: flex; + gap: 10px; + flex-wrap: wrap; } body.popup .submit-row { @@ -258,32 +271,29 @@ body.popup .submit-row { } .submit-row input { - height: 35px; - line-height: 15px; - margin: 0 0 0 5px; + height: 2.1875rem; + line-height: 0.9375rem; } -.submit-row input.default { - margin: 0 0 0 8px; - text-transform: uppercase; +.submit-row input, .submit-row a { + margin: 0; } -.submit-row p { - margin: 0.3em; +.submit-row input.default { + text-transform: uppercase; } -.submit-row p.deletelink-box { - float: left; - margin: 0; +.submit-row a.deletelink { + margin-left: auto; } .submit-row a.deletelink { display: block; background: var(--delete-button-bg); border-radius: 4px; - padding: 10px 15px; - height: 15px; - line-height: 15px; + padding: 0.625rem 0.9375rem; + height: 0.9375rem; + line-height: 0.9375rem; color: var(--button-fg); } @@ -292,9 +302,8 @@ body.popup .submit-row { background: var(--close-button-bg); border-radius: 4px; padding: 10px 15px; - height: 15px; - line-height: 15px; - margin: 0 0 0 5px; + height: 0.9375rem; + line-height: 0.9375rem; color: var(--button-fg); } @@ -302,12 +311,14 @@ body.popup .submit-row { .submit-row a.deletelink:hover, .submit-row a.deletelink:active { background: var(--delete-button-hover-bg); + text-decoration: none; } .submit-row a.closelink:focus, .submit-row a.closelink:hover, .submit-row a.closelink:active { background: var(--close-button-hover-bg); + text-decoration: none; } /* CUSTOM FORM FIELDS */ @@ -349,10 +360,6 @@ body.popup .submit-row { width: 2.2em; } -.vTextField, .vUUIDField { - width: 20em; -} - .vIntegerField { width: 5em; } @@ -365,6 +372,10 @@ body.popup .submit-row { width: 5em; } +.vTextField, .vUUIDField { + width: 20em; +} + /* INLINES */ .inline-group { @@ -388,7 +399,7 @@ body.popup .submit-row { margin: 0; color: var(--body-quiet-color); padding: 5px; - font-size: 13px; + font-size: 0.8125rem; background: var(--darkened-bg); border-top: 1px solid var(--hairline-color); border-bottom: 1px solid var(--hairline-color); @@ -400,7 +411,7 @@ body.popup .submit-row { .inline-related h3 span.delete label { margin-left: 2px; - font-size: 11px; + font-size: 0.6875rem; } .inline-related fieldset { @@ -413,7 +424,7 @@ body.popup .submit-row { .inline-related fieldset.module h3 { margin: 0; padding: 2px 5px 3px 5px; - font-size: 11px; + font-size: 0.6875rem; text-align: left; font-weight: bold; background: #bcd; @@ -454,7 +465,7 @@ body.popup .submit-row { height: 1.1em; padding: 2px 9px; overflow: hidden; - font-size: 9px; + font-size: 0.5625rem; font-weight: bold; color: var(--body-quiet-color); _width: 700px; @@ -489,7 +500,7 @@ body.popup .submit-row { .inline-group .tabular tr.add-row td a { background: url(../img/icon-addlink.svg) 0 1px no-repeat; padding-left: 16px; - font-size: 12px; + font-size: 0.75rem; } .empty-form { @@ -507,8 +518,8 @@ body.popup .submit-row { } .related-lookup { - width: 16px; - height: 16px; + width: 1rem; + height: 1rem; background-image: url(../img/search.svg); } diff --git a/app/static/admin/css/login.css b/app/static/admin/css/login.css index 10d9d22..389772f 100644 --- a/app/static/admin/css/login.css +++ b/app/static/admin/css/login.css @@ -12,7 +12,8 @@ } .login #header h1 { - font-size: 18px; + font-size: 1.125rem; + margin: 0; } .login #header h1 a { diff --git a/app/static/admin/css/nav_sidebar.css b/app/static/admin/css/nav_sidebar.css index f3c2fd8..f76e6ce 100644 --- a/app/static/admin/css/nav_sidebar.css +++ b/app/static/admin/css/nav_sidebar.css @@ -16,7 +16,7 @@ border-right: 1px solid var(--hairline-color); background-color: var(--body-bg); cursor: pointer; - font-size: 20px; + font-size: 1.25rem; color: var(--link-fg); padding: 0; } @@ -59,14 +59,16 @@ content: '\00AB'; } +.main > #nav-sidebar { + visibility: hidden; +} + .main.shifted > #nav-sidebar { - left: 24px; margin-left: 0; + visibility: visible; } [dir="rtl"] .main.shifted > #nav-sidebar { - left: 0; - right: 24px; margin-right: 0; } @@ -118,3 +120,25 @@ max-width: 100%; } } + +#nav-filter { + width: 100%; + box-sizing: border-box; + padding: 2px 5px; + margin: 5px 0; + border: 1px solid var(--border-color); + background-color: var(--darkened-bg); + color: var(--body-fg); +} + +#nav-filter:focus { + border-color: var(--body-quiet-color); +} + +#nav-filter.no-results { + background: var(--message-error-bg); +} + +#nav-sidebar table { + width: 100%; +} diff --git a/app/static/admin/css/responsive.css b/app/static/admin/css/responsive.css index 8c6dd81..1d0a188 100644 --- a/app/static/admin/css/responsive.css +++ b/app/static/admin/css/responsive.css @@ -14,11 +14,11 @@ input[type="submit"], button { td, th { padding: 10px; - font-size: 14px; + font-size: 0.875rem; } .small { - font-size: 12px; + font-size: 0.75rem; } /* Layout */ @@ -28,7 +28,7 @@ input[type="submit"], button { } #content { - padding: 20px 30px 30px; + padding: 15px 20px 20px; } div.breadcrumbs { @@ -45,7 +45,6 @@ input[type="submit"], button { #branding h1 { margin: 0 0 8px; - font-size: 20px; line-height: 1.2; } @@ -88,7 +87,7 @@ input[type="submit"], button { } td .changelink, td .addlink { - font-size: 13px; + font-size: 0.8125rem; } /* Changelist */ @@ -105,13 +104,13 @@ input[type="submit"], button { } #changelist-search label { - line-height: 22px; + line-height: 1.375rem; } #toolbar form #searchbar { flex: 1 0 auto; width: 0; - height: 22px; + height: 1.375rem; margin: 0 10px 0 6px; } @@ -131,10 +130,6 @@ input[type="submit"], button { padding: 15px 0; } - #changelist .actions.selected { - border: none; - } - #changelist .actions label { display: flex; } @@ -152,7 +147,7 @@ input[type="submit"], button { #changelist .actions span.clear, #changelist .actions span.question, #changelist .actions span.action-counter { - font-size: 11px; + font-size: 0.6875rem; margin: 0 10px 0 0; } @@ -176,7 +171,7 @@ input[type="submit"], button { /* Forms */ label { - font-size: 14px; + font-size: 0.875rem; } .form-row input[type=text], @@ -191,12 +186,12 @@ input[type="submit"], button { box-sizing: border-box; margin: 0; padding: 6px 8px; - min-height: 36px; - font-size: 14px; + min-height: 2.25rem; + font-size: 0.875rem; } .form-row select { - height: 36px; + height: 2.25rem; } .form-row select[multiple] { @@ -204,12 +199,6 @@ input[type="submit"], button { min-height: 0; } - fieldset .fieldBox { - float: none; - margin: 0 -10px; - padding: 0 10px; - } - fieldset .fieldBox + .fieldBox { margin-top: 10px; padding-top: 10px; @@ -232,10 +221,22 @@ input[type="submit"], button { margin-left: 15px; } - form .aligned ul.radiolist { + form .aligned div.radiolist { margin-left: 2px; } + .submit-row { + padding: 8px; + } + + .submit-row a.deletelink { + padding: 10px 7px; + } + + .button, input[type=submit], input[type=button], .submit-row input, a.button { + padding: 7px; + } + /* Related widget */ .related-widget-wrapper { @@ -383,22 +384,18 @@ input[type="submit"], button { display: none; } - form .form-row p.datetime { - width: 100%; - } - .datetime input { width: 50%; max-width: 120px; } .datetime span { - font-size: 13px; + font-size: 0.8125rem; } .datetime .timezonewarning { display: block; - font-size: 11px; + font-size: 0.6875rem; color: var(--body-quiet-color); } @@ -496,7 +493,7 @@ input[type="submit"], button { #content-related .module h2 { padding: 10px 15px; - font-size: 16px; + font-size: 1rem; } /* Changelist */ @@ -557,8 +554,6 @@ input[type="submit"], button { .aligned .form-row, .aligned .form-row > div { - display: flex; - flex-wrap: wrap; max-width: 100vw; } @@ -566,6 +561,14 @@ input[type="submit"], button { width: calc(100vw - 30px); } + .flex-container { + flex-flow: column; + } + + .flex-container.checkbox-row { + flex-flow: row; + } + textarea { max-width: none; } @@ -585,6 +588,7 @@ input[type="submit"], button { .aligned label { width: 100%; + min-width: auto; padding: 0 0 10px; } @@ -599,10 +603,6 @@ input[type="submit"], button { max-width: 100%; } - .aligned .checkbox-row { - align-items: center; - } - .aligned .checkbox-row input { flex: 0 1 auto; margin: 0; @@ -621,8 +621,7 @@ input[type="submit"], button { } .aligned p.file-upload { - margin-left: 0; - font-size: 13px; + font-size: 0.8125rem; } span.clearable-file-input { @@ -630,7 +629,7 @@ input[type="submit"], button { } span.clearable-file-input label { - font-size: 13px; + font-size: 0.8125rem; padding-bottom: 0; } @@ -645,17 +644,19 @@ input[type="submit"], button { padding: 0; } - form .aligned ul { + form .aligned ul, + form .aligned ul.errorlist { margin-left: 0; padding-left: 0; } - form .aligned ul.radiolist { + form .aligned div.radiolist { + margin-top: 5px; margin-right: 15px; margin-bottom: -3px; } - form .aligned ul.radiolist:not(.inline) li + li { + form .aligned div.radiolist:not(.inline) div + div { margin-top: 5px; } @@ -811,28 +812,23 @@ input[type="submit"], button { /* Submit row */ .submit-row { - padding: 10px 10px 0; + padding: 10px; margin: 0 0 15px; - display: flex; flex-direction: column; + gap: 8px; } - .submit-row > * { - width: 100%; - } - - .submit-row input, .submit-row input.default, .submit-row a, .submit-row a.closelink { - float: none; - margin: 0 0 10px; + .submit-row input, .submit-row input.default, .submit-row a { text-align: center; } .submit-row a.closelink { padding: 10px 0; + text-align: center; } - .submit-row p.deletelink-box { - order: 4; + .submit-row a.deletelink { + margin: 0; } /* Messages */ @@ -906,7 +902,7 @@ input[type="submit"], button { .errornote { margin: 0 0 20px; padding: 8px 12px; - font-size: 13px; + font-size: 0.8125rem; } /* Calendar and clock */ @@ -953,8 +949,8 @@ input[type="submit"], button { .calendar-shortcuts { padding: 10px 0; - font-size: 12px; - line-height: 12px; + font-size: 0.75rem; + line-height: 0.75rem; } .calendar-shortcuts a { @@ -986,7 +982,7 @@ input[type="submit"], button { /* History */ table#change-history tbody th, table#change-history tbody td { - font-size: 13px; + font-size: 0.8125rem; word-break: break-word; } @@ -997,7 +993,7 @@ input[type="submit"], button { /* Docs */ table.model tbody th, table.model tbody td { - font-size: 13px; + font-size: 0.8125rem; word-break: break-word; } } diff --git a/app/static/admin/css/responsive_rtl.css b/app/static/admin/css/responsive_rtl.css index 66d3c2f..31dc8ff 100644 --- a/app/static/admin/css/responsive_rtl.css +++ b/app/static/admin/css/responsive_rtl.css @@ -69,7 +69,8 @@ margin-right: 15px; } - [dir="rtl"] .aligned ul { + [dir="rtl"] .aligned ul, + [dir="rtl"] form .aligned ul.errorlist { margin-right: 0; } @@ -77,4 +78,7 @@ margin-left: 0; margin-right: 0; } + [dir="rtl"] .aligned .vCheckboxLabel { + padding: 1px 5px 0 0; + } } diff --git a/app/static/admin/css/rtl.css b/app/static/admin/css/rtl.css index 0447f89..c349a93 100644 --- a/app/static/admin/css/rtl.css +++ b/app/static/admin/css/rtl.css @@ -107,23 +107,25 @@ thead th.sorted .text { border-left: none; } -/* FORMS */ - -.aligned label { - padding: 0 0 3px 1em; - float: right; +.paginator .end { + margin-left: 6px; + margin-right: 0; } -.submit-row { - text-align: left +.paginator input { + margin-left: 0; + margin-right: auto; } -.submit-row p.deletelink-box { - float: right; +/* FORMS */ + +.aligned label { + padding: 0 0 3px 1em; } -.submit-row input.default { +.submit-row a.deletelink { margin-left: 0; + margin-right: auto; } .vDateField, .vTimeField { @@ -134,13 +136,11 @@ thead th.sorted .text { margin-left: 5px; } -form .aligned p.help, form .aligned div.help { - clear: right; -} - form .aligned ul { margin-right: 163px; + padding-right: 10px; margin-left: 0; + padding-left: 0; } form ul.inline li { @@ -149,12 +149,39 @@ form ul.inline li { padding-left: 7px; } -input[type=submit].default, .submit-row input.default { - float: left; +form .aligned p.help, +form .aligned div.help { + margin-right: 160px; + padding-right: 10px; +} + +form div.help ul, +form .aligned .checkbox-row + .help, +form .aligned p.date div.help.timezonewarning, +form .aligned p.datetime div.help.timezonewarning, +form .aligned p.time div.help.timezonewarning { + margin-right: 0; + padding-right: 0; +} + +form .wide p.help, form .wide div.help { + padding-left: 0; + padding-right: 50px; +} + +form .wide p, +form .wide ul.errorlist, +form .wide input + p.help, +form .wide input + div.help { + margin-right: 200px; + margin-left: 0px; +} + +.submit-row { + text-align: right; } fieldset .fieldBox { - float: right; margin-left: 20px; margin-right: 0; } @@ -175,12 +202,24 @@ fieldset .fieldBox { top: 0; left: auto; right: 10px; + background: url(../img/calendar-icons.svg) 0 -30px no-repeat; +} + +.calendarbox .calendarnav-previous:focus, +.calendarbox .calendarnav-previous:hover { + background-position: 0 -45px; } .calendarnav-next { top: 0; right: auto; left: 10px; + background: url(../img/calendar-icons.svg) 0 0 no-repeat; +} + +.calendarbox .calendarnav-next:focus, +.calendarbox .calendarnav-next:hover { + background-position: 0 -15px; } .calendar caption, .calendarbox h2 { @@ -195,6 +234,38 @@ fieldset .fieldBox { text-align: right; } +.selector-add { + background: url(../img/selector-icons.svg) 0 -64px no-repeat; +} + +.active.selector-add:focus, .active.selector-add:hover { + background-position: 0 -80px; +} + +.selector-remove { + background: url(../img/selector-icons.svg) 0 -96px no-repeat; +} + +.active.selector-remove:focus, .active.selector-remove:hover { + background-position: 0 -112px; +} + +a.selector-chooseall { + background: url(../img/selector-icons.svg) right -128px no-repeat; +} + +a.active.selector-chooseall:focus, a.active.selector-chooseall:hover { + background-position: 100% -144px; +} + +a.selector-clearall { + background: url(../img/selector-icons.svg) 0 -160px no-repeat; +} + +a.active.selector-clearall:focus, a.active.selector-clearall:hover { + background-position: 0 -176px; +} + .inline-deletelink { float: left; } diff --git a/app/static/admin/css/widgets.css b/app/static/admin/css/widgets.css index c7d6456..1104e8b 100644 --- a/app/static/admin/css/widgets.css +++ b/app/static/admin/css/widgets.css @@ -3,22 +3,21 @@ .selector { width: 800px; float: left; + display: flex; } .selector select { width: 380px; height: 17.2em; + flex: 1 0 auto; } .selector-available, .selector-chosen { - float: left; width: 380px; text-align: center; margin-bottom: 5px; -} - -.selector-chosen select { - border-top: none; + display: flex; + flex-direction: column; } .selector-available h2, .selector-chosen h2 { @@ -26,6 +25,21 @@ border-radius: 4px 4px 0 0; } +.selector-chosen .list-footer-display { + border: 1px solid var(--border-color); + border-top: none; + border-radius: 0 0 4px 4px; + margin: 0 0 10px; + padding: 8px; + text-align: center; + background: var(--primary); + color: var(--header-link-color); + cursor: pointer; +} +.selector-chosen .list-footer-display__clear { + color: var(--breadcrumbs-fg); +} + .selector-chosen h2 { background: var(--primary); color: var(--header-link-color); @@ -41,7 +55,7 @@ border-width: 0 1px; padding: 8px; color: var(--body-quiet-color); - font-size: 10px; + font-size: 0.625rem; margin: 0; text-align: left; } @@ -55,20 +69,23 @@ padding: 0; overflow: hidden; line-height: 1; + min-width: auto; } -.selector .selector-available input { +.selector .selector-available input, +.selector .selector-chosen input { width: 320px; margin-left: 8px; } .selector ul.selector-chooser { - float: left; + align-self: center; width: 22px; background-color: var(--selected-bg); border-radius: 10px; - margin: 10em 5px 0 5px; + margin: 0 5px; padding: 0; + transform: translateY(-17px); } .selector-chooser li { @@ -82,6 +99,15 @@ margin: 0 0 10px; border-radius: 0 0 4px 4px; } +.selector .selector-chosen--with-filtered select { + margin: 0; + border-radius: 0; + height: 14em; +} + +.selector .selector-chosen:not(.selector-chosen--with-filtered) .list-footer-display { + display: none; +} .selector-add, .selector-remove { width: 16px; @@ -168,6 +194,7 @@ a.active.selector-clearall:focus, a.active.selector-clearall:hover { .stacked { float: left; width: 490px; + display: block; } .stacked select { @@ -193,6 +220,7 @@ a.active.selector-clearall:focus, a.active.selector-clearall:hover { margin: 0 0 10px 40%; background-color: #eee; border-radius: 10px; + transform: none; } .stacked .selector-chooser li { @@ -250,8 +278,8 @@ a.active.selector-clearall:focus, a.active.selector-clearall:hover { .selector .search-label-icon { background: url(../img/search.svg) 0 0 no-repeat; display: inline-block; - height: 18px; - width: 18px; + height: 1.125rem; + width: 1.125rem; } /* DATE AND TIME */ @@ -267,7 +295,7 @@ p.datetime { .datetime span { white-space: nowrap; font-weight: normal; - font-size: 11px; + font-size: 0.6875rem; color: var(--body-quiet-color); } @@ -277,7 +305,7 @@ p.datetime { } table p.datetime { - font-size: 11px; + font-size: 0.6875rem; margin-left: 0; padding-left: 0; } @@ -311,7 +339,7 @@ table p.datetime { } .timezonewarning { - font-size: 11px; + font-size: 0.6875rem; color: var(--body-quiet-color); } @@ -322,7 +350,7 @@ p.url { margin: 0; padding: 0; color: var(--body-quiet-color); - font-size: 11px; + font-size: 0.6875rem; font-weight: bold; } @@ -337,14 +365,10 @@ p.file-upload { margin: 0; padding: 0; color: var(--body-quiet-color); - font-size: 11px; + font-size: 0.6875rem; font-weight: bold; } -.aligned p.file-upload { - margin-left: 170px; -} - .file-upload a { font-weight: normal; } @@ -355,7 +379,7 @@ p.file-upload { span.clearable-file-input label { color: var(--body-fg); - font-size: 11px; + font-size: 0.6875rem; display: inline; float: none; } @@ -364,7 +388,7 @@ span.clearable-file-input label { .calendarbox, .clockbox { margin: 5px auto; - font-size: 12px; + font-size: 0.75rem; width: 19em; text-align: center; background: var(--body-bg); @@ -398,7 +422,7 @@ span.clearable-file-input label { text-align: center; border-top: none; font-weight: 700; - font-size: 12px; + font-size: 0.75rem; color: #333; background: var(--accent); } @@ -408,14 +432,14 @@ span.clearable-file-input label { background: var(--darkened-bg); border-bottom: 1px solid var(--border-color); font-weight: 400; - font-size: 12px; + font-size: 0.75rem; text-align: center; color: var(--body-quiet-color); } .calendar td { font-weight: 400; - font-size: 12px; + font-size: 0.75rem; text-align: center; padding: 0; border-top: 1px solid var(--hairline-color); @@ -455,7 +479,7 @@ span.clearable-file-input label { } .calendarnav { - font-size: 10px; + font-size: 0.625rem; text-align: center; color: #ccc; margin: 0; @@ -470,8 +494,8 @@ span.clearable-file-input label { .calendar-shortcuts { background: var(--body-bg); color: var(--body-quiet-color); - font-size: 11px; - line-height: 11px; + font-size: 0.6875rem; + line-height: 0.6875rem; border-top: 1px solid var(--hairline-color); padding: 8px 0; } @@ -509,7 +533,7 @@ span.clearable-file-input label { .calendar-cancel { margin: 0; padding: 4px 0; - font-size: 12px; + font-size: 0.75rem; background: #eee; border-top: 1px solid var(--border-color); color: var(--body-fg); @@ -572,3 +596,9 @@ select + .related-widget-wrapper-link, .related-widget-wrapper-link + .related-widget-wrapper-link { margin-left: 7px; } + +/* GIS MAPS */ +.dj_map { + width: 600px; + height: 400px; +} diff --git a/app/static/admin/fonts/LICENSE.txt b/app/static/admin/fonts/LICENSE.txt index 75b5248..d645695 100644 --- a/app/static/admin/fonts/LICENSE.txt +++ b/app/static/admin/fonts/LICENSE.txt @@ -1,202 +1,202 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/app/static/admin/js/SelectBox.js b/app/static/admin/js/SelectBox.js index ace6d9d..3db4ec7 100644 --- a/app/static/admin/js/SelectBox.js +++ b/app/static/admin/js/SelectBox.js @@ -41,6 +41,10 @@ } SelectBox.redisplay(id); }, + get_hidden_node_count(id) { + const cache = SelectBox.cache[id] || []; + return cache.filter(node => node.displayed === 0).length; + }, delete_from_cache: function(id, value) { let delete_index = null; const cache = SelectBox.cache[id]; diff --git a/app/static/admin/js/SelectFilter2.js b/app/static/admin/js/SelectFilter2.js index 6c709a0..9a4e0a3 100644 --- a/app/static/admin/js/SelectFilter2.js +++ b/app/static/admin/js/SelectFilter2.js @@ -78,7 +78,7 @@ Requires core.js and SelectBox.js. remove_link.className = 'selector-remove'; //
- const selector_chosen = quickElement('div', selector_div); + const selector_chosen = quickElement('div', selector_div, '', 'id', field_id + '_selector_chosen'); selector_chosen.className = 'selector-chosen'; const title_chosen = quickElement('h2', selector_chosen, interpolate(gettext('Chosen %s') + ' ', [field_name])); quickElement( @@ -93,9 +93,30 @@ Requires core.js and SelectBox.js. [field_name] ) ); + + const filter_selected_p = quickElement('p', selector_chosen, '', 'id', field_id + '_filter_selected'); + filter_selected_p.className = 'selector-filter'; + + const search_filter_selected_label = quickElement('label', filter_selected_p, '', 'for', field_id + '_selected_input'); + + quickElement( + 'span', search_filter_selected_label, '', + 'class', 'help-tooltip search-label-icon', + 'title', interpolate(gettext("Type into this box to filter down the list of selected %s."), [field_name]) + ); + + filter_selected_p.appendChild(document.createTextNode(' ')); + + const filter_selected_input = quickElement('input', filter_selected_p, '', 'type', 'text', 'placeholder', gettext("Filter")); + filter_selected_input.id = field_id + '_selected_input'; const to_box = quickElement('select', selector_chosen, '', 'id', field_id + '_to', 'multiple', '', 'size', from_box.size, 'name', from_box.name); to_box.className = 'filtered'; + + const warning_footer = quickElement('div', selector_chosen, '', 'class', 'list-footer-display'); + quickElement('span', warning_footer, '', 'id', field_id + '_list-footer-display-text'); + quickElement('span', warning_footer, ' (click to clear)', 'class', 'list-footer-display__clear'); + const clear_all = quickElement('a', selector_chosen, gettext('Remove all'), 'title', interpolate(gettext('Click to remove all chosen %s at once.'), [field_name]), 'href', '#', 'id', field_id + '_remove_all_link'); clear_all.className = 'selector-clearall'; @@ -106,6 +127,8 @@ Requires core.js and SelectBox.js. if (elem.classList.contains('active')) { move_func(from, to); SelectFilter.refresh_icons(field_id); + SelectFilter.refresh_filtered_selects(field_id); + SelectFilter.refresh_filtered_warning(field_id); } e.preventDefault(); }; @@ -121,14 +144,29 @@ Requires core.js and SelectBox.js. clear_all.addEventListener('click', function(e) { move_selection(e, this, SelectBox.move_all, field_id + '_to', field_id + '_from'); }); + warning_footer.addEventListener('click', function(e) { + filter_selected_input.value = ''; + SelectBox.filter(field_id + '_to', ''); + SelectFilter.refresh_filtered_warning(field_id); + SelectFilter.refresh_icons(field_id); + }); filter_input.addEventListener('keypress', function(e) { - SelectFilter.filter_key_press(e, field_id); + SelectFilter.filter_key_press(e, field_id, '_from', '_to'); }); filter_input.addEventListener('keyup', function(e) { - SelectFilter.filter_key_up(e, field_id); + SelectFilter.filter_key_up(e, field_id, '_from'); }); filter_input.addEventListener('keydown', function(e) { - SelectFilter.filter_key_down(e, field_id); + SelectFilter.filter_key_down(e, field_id, '_from', '_to'); + }); + filter_selected_input.addEventListener('keypress', function(e) { + SelectFilter.filter_key_press(e, field_id, '_to', '_from'); + }); + filter_selected_input.addEventListener('keyup', function(e) { + SelectFilter.filter_key_up(e, field_id, '_to', '_selected_input'); + }); + filter_selected_input.addEventListener('keydown', function(e) { + SelectFilter.filter_key_down(e, field_id, '_to', '_from'); }); selector_div.addEventListener('change', function(e) { if (e.target.tagName === 'SELECT') { @@ -146,6 +184,7 @@ Requires core.js and SelectBox.js. } }); from_box.closest('form').addEventListener('submit', function() { + SelectBox.filter(field_id + '_to', ''); SelectBox.select_all(field_id + '_to'); }); SelectBox.init(field_id + '_from'); @@ -153,24 +192,6 @@ Requires core.js and SelectBox.js. // Move selected from_box options to to_box SelectBox.move(field_id + '_from', field_id + '_to'); - if (!is_stacked) { - // In horizontal mode, give the same height to the two boxes. - const j_from_box = document.getElementById(field_id + '_from'); - const j_to_box = document.getElementById(field_id + '_to'); - let height = filter_p.offsetHeight + j_from_box.offsetHeight; - - const j_to_box_style = window.getComputedStyle(j_to_box); - if (j_to_box_style.getPropertyValue('box-sizing') === 'border-box') { - // Add the padding and border to the final height. - height += parseInt(j_to_box_style.getPropertyValue('padding-top'), 10) - + parseInt(j_to_box_style.getPropertyValue('padding-bottom'), 10) - + parseInt(j_to_box_style.getPropertyValue('border-top-width'), 10) - + parseInt(j_to_box_style.getPropertyValue('border-bottom-width'), 10); - } - - j_to_box.style.height = height + 'px'; - } - // Initial icon refresh SelectFilter.refresh_icons(field_id); }, @@ -181,6 +202,24 @@ Requires core.js and SelectBox.js. field.required = false; return any_selected; }, + refresh_filtered_warning: function(field_id) { + const count = SelectBox.get_hidden_node_count(field_id + '_to'); + const selector = document.getElementById(field_id + '_selector_chosen'); + const warning = document.getElementById(field_id + '_list-footer-display-text'); + selector.className = selector.className.replace('selector-chosen--with-filtered', ''); + warning.textContent = interpolate(ngettext( + '%s selected option not visible', + '%s selected options not visible', + count + ), [count]); + if(count > 0) { + selector.className += ' selector-chosen--with-filtered'; + } + }, + refresh_filtered_selects: function(field_id) { + SelectBox.filter(field_id + '_from', document.getElementById(field_id + "_input").value); + SelectBox.filter(field_id + '_to', document.getElementById(field_id + "_selected_input").value); + }, refresh_icons: function(field_id) { const from = document.getElementById(field_id + '_from'); const to = document.getElementById(field_id + '_to'); @@ -190,39 +229,47 @@ Requires core.js and SelectBox.js. // Active if the corresponding box isn't empty document.getElementById(field_id + '_add_all_link').classList.toggle('active', from.querySelector('option')); document.getElementById(field_id + '_remove_all_link').classList.toggle('active', to.querySelector('option')); + SelectFilter.refresh_filtered_warning(field_id); }, - filter_key_press: function(event, field_id) { - const from = document.getElementById(field_id + '_from'); + filter_key_press: function(event, field_id, source, target) { + const source_box = document.getElementById(field_id + source); // don't submit form if user pressed Enter if ((event.which && event.which === 13) || (event.keyCode && event.keyCode === 13)) { - from.selectedIndex = 0; - SelectBox.move(field_id + '_from', field_id + '_to'); - from.selectedIndex = 0; + source_box.selectedIndex = 0; + SelectBox.move(field_id + source, field_id + target); + source_box.selectedIndex = 0; event.preventDefault(); } }, - filter_key_up: function(event, field_id) { - const from = document.getElementById(field_id + '_from'); - const temp = from.selectedIndex; - SelectBox.filter(field_id + '_from', document.getElementById(field_id + '_input').value); - from.selectedIndex = temp; + filter_key_up: function(event, field_id, source, filter_input) { + const input = filter_input || '_input'; + const source_box = document.getElementById(field_id + source); + const temp = source_box.selectedIndex; + SelectBox.filter(field_id + source, document.getElementById(field_id + input).value); + source_box.selectedIndex = temp; + SelectFilter.refresh_filtered_warning(field_id); + SelectFilter.refresh_icons(field_id); }, - filter_key_down: function(event, field_id) { - const from = document.getElementById(field_id + '_from'); + filter_key_down: function(event, field_id, source, target) { + const source_box = document.getElementById(field_id + source); + // right key (39) or left key (37) + const direction = source === '_from' ? 39 : 37; // right arrow -- move across - if ((event.which && event.which === 39) || (event.keyCode && event.keyCode === 39)) { - const old_index = from.selectedIndex; - SelectBox.move(field_id + '_from', field_id + '_to'); - from.selectedIndex = (old_index === from.length) ? from.length - 1 : old_index; + if ((event.which && event.which === direction) || (event.keyCode && event.keyCode === direction)) { + const old_index = source_box.selectedIndex; + SelectBox.move(field_id + source, field_id + target); + SelectFilter.refresh_filtered_selects(field_id); + SelectFilter.refresh_filtered_warning(field_id); + source_box.selectedIndex = (old_index === source_box.length) ? source_box.length - 1 : old_index; return; } // down arrow -- wrap around if ((event.which && event.which === 40) || (event.keyCode && event.keyCode === 40)) { - from.selectedIndex = (from.length === from.selectedIndex + 1) ? 0 : from.selectedIndex + 1; + source_box.selectedIndex = (source_box.length === source_box.selectedIndex + 1) ? 0 : source_box.selectedIndex + 1; } // up arrow -- wrap around if ((event.which && event.which === 38) || (event.keyCode && event.keyCode === 38)) { - from.selectedIndex = (from.selectedIndex === 0) ? from.length - 1 : from.selectedIndex - 1; + source_box.selectedIndex = (source_box.selectedIndex === 0) ? source_box.length - 1 : source_box.selectedIndex - 1; } } }; diff --git a/app/static/admin/js/actions.js b/app/static/admin/js/actions.js index 2830e91..20a5c14 100644 --- a/app/static/admin/js/actions.js +++ b/app/static/admin/js/actions.js @@ -156,7 +156,7 @@ }); }); - document.querySelector('#changelist-form button[name=index]').addEventListener('click', function() { + document.querySelector('#changelist-form button[name=index]').addEventListener('click', function(event) { if (list_editable_changed) { const confirmed = confirm(gettext("You have unsaved changes on individual editable fields. If you run an action, your unsaved changes will be lost.")); if (!confirmed) { diff --git a/app/static/admin/js/admin/DateTimeShortcuts.js b/app/static/admin/js/admin/DateTimeShortcuts.js index 9bad0f5..aa1cae9 100644 --- a/app/static/admin/js/admin/DateTimeShortcuts.js +++ b/app/static/admin/js/admin/DateTimeShortcuts.js @@ -90,10 +90,9 @@ } message = interpolate(message, [timezoneOffset]); - const warning = document.createElement('span'); - warning.className = warningClass; + const warning = document.createElement('div'); + warning.classList.add('help', warningClass); warning.textContent = message; - inp.parentNode.appendChild(document.createElement('br')); inp.parentNode.appendChild(warning); }, // Add clock widget to a given field @@ -388,13 +387,7 @@ DateTimeShortcuts.calendars[num].drawNextMonth(); }, handleCalendarCallback: function(num) { - let format = get_format('DATE_INPUT_FORMATS')[0]; - // the format needs to be escaped a little - format = format.replace('\\', '\\\\') - .replace('\r', '\\r') - .replace('\n', '\\n') - .replace('\t', '\\t') - .replace("'", "\\'"); + const format = get_format('DATE_INPUT_FORMATS')[0]; return function(y, m, d) { DateTimeShortcuts.calendarInputs[num].value = new Date(y, m - 1, d).strftime(format); DateTimeShortcuts.calendarInputs[num].focus(); diff --git a/app/static/admin/js/admin/RelatedObjectLookups.js b/app/static/admin/js/admin/RelatedObjectLookups.js index 289e1ce..afb6b66 100644 --- a/app/static/admin/js/admin/RelatedObjectLookups.js +++ b/app/static/admin/js/admin/RelatedObjectLookups.js @@ -4,14 +4,43 @@ 'use strict'; { const $ = django.jQuery; + let popupIndex = 0; + const relatedWindows = []; + + function dismissChildPopups() { + relatedWindows.forEach(function(win) { + if(!win.closed) { + win.dismissChildPopups(); + win.close(); + } + }); + } + + function setPopupIndex() { + if(document.getElementsByName("_popup").length > 0) { + const index = window.name.lastIndexOf("__") + 2; + popupIndex = parseInt(window.name.substring(index)); + } else { + popupIndex = 0; + } + } + + function addPopupIndex(name) { + return name + "__" + (popupIndex + 1); + } + + function removePopupIndex(name) { + return name.replace(new RegExp("__" + (popupIndex + 1) + "$"), ''); + } function showAdminPopup(triggeringLink, name_regexp, add_popup) { - const name = triggeringLink.id.replace(name_regexp, ''); + const name = addPopupIndex(triggeringLink.id.replace(name_regexp, '')); const href = new URL(triggeringLink.href); if (add_popup) { href.searchParams.set('_popup', 1); } const win = window.open(href, name, 'height=500,width=800,resizable=yes,scrollbars=yes'); + relatedWindows.push(win); win.focus(); return false; } @@ -21,13 +50,17 @@ } function dismissRelatedLookupPopup(win, chosenId) { - const name = win.name; + const name = removePopupIndex(win.name); const elem = document.getElementById(name); if (elem.classList.contains('vManyToManyRawIdAdminField') && elem.value) { elem.value += ',' + chosenId; } else { document.getElementById(name).value = chosenId; } + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } win.close(); } @@ -52,13 +85,44 @@ } } + function updateRelatedSelectsOptions(currentSelect, win, objId, newRepr, newId) { + // After create/edit a model from the options next to the current + // select (+ or :pencil:) update ForeignKey PK of the rest of selects + // in the page. + + const path = win.location.pathname; + // Extract the model from the popup url '...//add/' or + // '...///change/' depending the action (add or change). + const modelName = path.split('/')[path.split('/').length - (objId ? 4 : 3)]; + // Exclude autocomplete selects. + const selectsRelated = document.querySelectorAll(`[data-model-ref="${modelName}"] select:not(.admin-autocomplete)`); + + selectsRelated.forEach(function(select) { + if (currentSelect === select) { + return; + } + + let option = select.querySelector(`option[value="${objId}"]`); + + if (!option) { + option = new Option(newRepr, newId); + select.options.add(option); + return; + } + + option.textContent = newRepr; + option.value = newId; + }); + } + function dismissAddRelatedObjectPopup(win, newId, newRepr) { - const name = win.name; + const name = removePopupIndex(win.name); const elem = document.getElementById(name); if (elem) { const elemName = elem.nodeName.toUpperCase(); if (elemName === 'SELECT') { elem.options[elem.options.length] = new Option(newRepr, newId, true, true); + updateRelatedSelectsOptions(elem, win, null, newRepr, newId); } else if (elemName === 'INPUT') { if (elem.classList.contains('vManyToManyRawIdAdminField') && elem.value) { elem.value += ',' + newId; @@ -74,11 +138,15 @@ SelectBox.add_to_cache(toId, o); SelectBox.redisplay(toId); } + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } win.close(); } function dismissChangeRelatedObjectPopup(win, objId, newRepr, newId) { - const id = win.name.replace(/^edit_/, ''); + const id = removePopupIndex(win.name.replace(/^edit_/, '')); const selectsSelector = interpolate('#%s, #%s_from, #%s_to', [id, id, id]); const selects = $(selectsSelector); selects.find('option').each(function() { @@ -86,18 +154,23 @@ this.textContent = newRepr; this.value = newId; } - }); + }).trigger('change'); + updateRelatedSelectsOptions(selects[0], win, objId, newRepr, newId); selects.next().find('.select2-selection__rendered').each(function() { // The element can have a clear button as a child. // Use the lastChild to modify only the displayed value. this.lastChild.textContent = newRepr; this.title = newRepr; }); + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } win.close(); } function dismissDeleteRelatedObjectPopup(win, objId) { - const id = win.name.replace(/^delete_/, ''); + const id = removePopupIndex(win.name.replace(/^delete_/, '')); const selectsSelector = interpolate('#%s, #%s_from, #%s_to', [id, id, id]); const selects = $(selectsSelector); selects.find('option').each(function() { @@ -105,6 +178,10 @@ $(this).remove(); } }).trigger('change'); + const index = relatedWindows.indexOf(win); + if (index > -1) { + relatedWindows.splice(index, 1); + } win.close(); } @@ -115,17 +192,23 @@ window.dismissAddRelatedObjectPopup = dismissAddRelatedObjectPopup; window.dismissChangeRelatedObjectPopup = dismissChangeRelatedObjectPopup; window.dismissDeleteRelatedObjectPopup = dismissDeleteRelatedObjectPopup; + window.dismissChildPopups = dismissChildPopups; // Kept for backward compatibility window.showAddAnotherPopup = showRelatedObjectPopup; window.dismissAddAnotherPopup = dismissAddRelatedObjectPopup; + window.addEventListener('unload', function(evt) { + window.dismissChildPopups(); + }); + $(document).ready(function() { + setPopupIndex(); $("a[data-popup-opener]").on('click', function(event) { event.preventDefault(); opener.dismissRelatedLookupPopup(window, $(this).data("popup-opener")); }); - $('body').on('click', '.related-widget-wrapper-link', function(e) { + $('body').on('click', '.related-widget-wrapper-link[data-popup="yes"]', function(e) { e.preventDefault(); if (this.href) { const event = $.Event('django:show-related', {href: this.href}); diff --git a/app/static/admin/js/autocomplete.js b/app/static/admin/js/autocomplete.js index c55eee1..d3daeab 100644 --- a/app/static/admin/js/autocomplete.js +++ b/app/static/admin/js/autocomplete.js @@ -1,28 +1,22 @@ 'use strict'; { const $ = django.jQuery; - const init = function($element, options) { - const settings = $.extend({ - ajax: { - data: function(params) { - return { - term: params.term, - page: params.page, - app_label: $element.data('app-label'), - model_name: $element.data('model-name'), - field_name: $element.data('field-name') - }; - } - } - }, options); - $element.select2(settings); - }; - $.fn.djangoAdminSelect2 = function(options) { - const settings = $.extend({}, options); + $.fn.djangoAdminSelect2 = function() { $.each(this, function(i, element) { - const $element = $(element); - init($element, settings); + $(element).select2({ + ajax: { + data: (params) => { + return { + term: params.term, + page: params.page, + app_label: element.dataset.appLabel, + model_name: element.dataset.modelName, + field_name: element.dataset.fieldName + }; + } + } + }); }); return this; }; @@ -33,9 +27,7 @@ $('.admin-autocomplete').not('[name*=__prefix__]').djangoAdminSelect2(); }); - $(document).on('formset:added', (function() { - return function(event, $newFormset) { - return $newFormset.find('.admin-autocomplete').djangoAdminSelect2(); - }; - })(this)); + document.addEventListener('formset:added', (event) => { + $(event.target).find('.admin-autocomplete').djangoAdminSelect2(); + }); } diff --git a/app/static/admin/js/core.js b/app/static/admin/js/core.js index 3a2e4aa..0344a13 100644 --- a/app/static/admin/js/core.js +++ b/app/static/admin/js/core.js @@ -1,4 +1,4 @@ -// Core javascript helper functions +// Core JavaScript helper functions 'use strict'; // quickElement(tagType, parentReference [, textInChildNode, attribute, attributeValue ...]); @@ -119,11 +119,11 @@ function findPosY(obj) { let result = '', i = 0; while (i < format.length) { if (format.charAt(i) === '%') { - result = result + fields[format.charAt(i + 1)]; + result += fields[format.charAt(i + 1)]; ++i; } else { - result = result + format.charAt(i); + result += format.charAt(i); } ++i; } diff --git a/app/static/admin/js/filters.js b/app/static/admin/js/filters.js new file mode 100644 index 0000000..f5536eb --- /dev/null +++ b/app/static/admin/js/filters.js @@ -0,0 +1,30 @@ +/** + * Persist changelist filters state (collapsed/expanded). + */ +'use strict'; +{ + // Init filters. + let filters = JSON.parse(sessionStorage.getItem('django.admin.filtersState')); + + if (!filters) { + filters = {}; + } + + Object.entries(filters).forEach(([key, value]) => { + const detailElement = document.querySelector(`[data-filter-title='${CSS.escape(key)}']`); + + // Check if the filter is present, it could be from other view. + if (detailElement) { + value ? detailElement.setAttribute('open', '') : detailElement.removeAttribute('open'); + } + }); + + // Save filter state when clicks. + const details = document.querySelectorAll('details'); + details.forEach(detail => { + detail.addEventListener('toggle', event => { + filters[`${event.target.dataset.filterTitle}`] = detail.open; + sessionStorage.setItem('django.admin.filtersState', JSON.stringify(filters)); + }); + }); +} diff --git a/app/static/admin/js/inlines.js b/app/static/admin/js/inlines.js index 82ec027..e9a1dfe 100644 --- a/app/static/admin/js/inlines.js +++ b/app/static/admin/js/inlines.js @@ -88,7 +88,12 @@ if (options.added) { options.added(row); } - $(document).trigger('formset:added', [row, options.prefix]); + row.get(0).dispatchEvent(new CustomEvent("formset:added", { + bubbles: true, + detail: { + formsetName: options.prefix + } + })); }; /** @@ -130,7 +135,11 @@ if (options.removed) { options.removed(row); } - $(document).trigger('formset:removed', [row, options.prefix]); + document.dispatchEvent(new CustomEvent("formset:removed", { + detail: { + formsetName: options.prefix + } + })); // Update the TOTAL_FORMS form count. const forms = $("." + options.formCssClass); $("#id_" + options.prefix + "-TOTAL_FORMS").val(forms.length); @@ -218,12 +227,10 @@ // instantiate a new SelectFilter instance for it. if (typeof SelectFilter !== 'undefined') { $('.selectfilter').each(function(index, value) { - const namearr = value.name.split('-'); - SelectFilter.init(value.id, namearr[namearr.length - 1], false); + SelectFilter.init(value.id, this.dataset.fieldName, false); }); $('.selectfilterstacked').each(function(index, value) { - const namearr = value.name.split('-'); - SelectFilter.init(value.id, namearr[namearr.length - 1], true); + SelectFilter.init(value.id, this.dataset.fieldName, true); }); } }; @@ -283,12 +290,10 @@ // If any SelectFilter widgets were added, instantiate a new instance. if (typeof SelectFilter !== "undefined") { $(".selectfilter").each(function(index, value) { - const namearr = value.name.split('-'); - SelectFilter.init(value.id, namearr[namearr.length - 1], false); + SelectFilter.init(value.id, this.dataset.fieldName, false); }); $(".selectfilterstacked").each(function(index, value) { - const namearr = value.name.split('-'); - SelectFilter.init(value.id, namearr[namearr.length - 1], true); + SelectFilter.init(value.id, this.dataset.fieldName, true); }); } }; @@ -300,7 +305,13 @@ dependency_list = input.data('dependency_list') || [], dependencies = []; $.each(dependency_list, function(i, field_name) { - dependencies.push('#' + row.find('.form-row .field-' + field_name).find('input, select, textarea').attr('id')); + // Dependency in a fieldset. + let field_element = row.find('.form-row .field-' + field_name); + // Dependency without a fieldset. + if (!field_element.length) { + field_element = row.find('.form-row.field-' + field_name); + } + dependencies.push('#' + field_element.find('input, select, textarea').attr('id')); }); if (dependencies.length) { input.prepopulate(dependencies, input.attr('maxlength')); diff --git a/app/static/admin/js/nav_sidebar.js b/app/static/admin/js/nav_sidebar.js index efaa721..7e735db 100644 --- a/app/static/admin/js/nav_sidebar.js +++ b/app/static/admin/js/nav_sidebar.js @@ -2,38 +2,78 @@ { const toggleNavSidebar = document.getElementById('toggle-nav-sidebar'); if (toggleNavSidebar !== null) { - const navLinks = document.querySelectorAll('#nav-sidebar a'); - function disableNavLinkTabbing() { - for (const navLink of navLinks) { - navLink.tabIndex = -1; - } - } - function enableNavLinkTabbing() { - for (const navLink of navLinks) { - navLink.tabIndex = 0; - } - } - + const navSidebar = document.getElementById('nav-sidebar'); const main = document.getElementById('main'); let navSidebarIsOpen = localStorage.getItem('django.admin.navSidebarIsOpen'); if (navSidebarIsOpen === null) { navSidebarIsOpen = 'true'; } - if (navSidebarIsOpen === 'false') { - disableNavLinkTabbing(); - } main.classList.toggle('shifted', navSidebarIsOpen === 'true'); + navSidebar.setAttribute('aria-expanded', navSidebarIsOpen); toggleNavSidebar.addEventListener('click', function() { if (navSidebarIsOpen === 'true') { navSidebarIsOpen = 'false'; - disableNavLinkTabbing(); } else { navSidebarIsOpen = 'true'; - enableNavLinkTabbing(); } localStorage.setItem('django.admin.navSidebarIsOpen', navSidebarIsOpen); main.classList.toggle('shifted'); + navSidebar.setAttribute('aria-expanded', navSidebarIsOpen); + }); + } + + function initSidebarQuickFilter() { + const options = []; + const navSidebar = document.getElementById('nav-sidebar'); + if (!navSidebar) { + return; + } + navSidebar.querySelectorAll('th[scope=row] a').forEach((container) => { + options.push({title: container.innerHTML, node: container}); }); + + function checkValue(event) { + let filterValue = event.target.value; + if (filterValue) { + filterValue = filterValue.toLowerCase(); + } + if (event.key === 'Escape') { + filterValue = ''; + event.target.value = ''; // clear input + } + let matches = false; + for (const o of options) { + let displayValue = ''; + if (filterValue) { + if (o.title.toLowerCase().indexOf(filterValue) === -1) { + displayValue = 'none'; + } else { + matches = true; + } + } + // show/hide parent + o.node.parentNode.parentNode.style.display = displayValue; + } + if (!filterValue || matches) { + event.target.classList.remove('no-results'); + } else { + event.target.classList.add('no-results'); + } + sessionStorage.setItem('django.admin.navSidebarFilterValue', filterValue); + } + + const nav = document.getElementById('nav-filter'); + nav.addEventListener('change', checkValue, false); + nav.addEventListener('input', checkValue, false); + nav.addEventListener('keyup', checkValue, false); + + const storedValue = sessionStorage.getItem('django.admin.navSidebarFilterValue'); + if (storedValue) { + nav.value = storedValue; + checkValue({target: nav, key: ''}); + } } + window.initSidebarQuickFilter = initSidebarQuickFilter; + initSidebarQuickFilter(); } diff --git a/app/static/admin/js/prepopulate_init.js b/app/static/admin/js/prepopulate_init.js index 72ebdcf..a58841f 100644 --- a/app/static/admin/js/prepopulate_init.js +++ b/app/static/admin/js/prepopulate_init.js @@ -3,7 +3,11 @@ const $ = django.jQuery; const fields = $('#django-admin-prepopulated-fields-constants').data('prepopulatedFields'); $.each(fields, function(index, field) { - $('.empty-form .form-row .field-' + field.name + ', .empty-form.form-row .field-' + field.name).addClass('prepopulated_field'); + $( + '.empty-form .form-row .field-' + field.name + + ', .empty-form.form-row .field-' + field.name + + ', .empty-form .form-row.field-' + field.name + ).addClass('prepopulated_field'); $(field.id).data('dependency_list', field.dependency_list).prepopulate( field.dependency_ids, field.maxLength, field.allowUnicode ); diff --git a/app/static/admin/js/theme.js b/app/static/admin/js/theme.js new file mode 100644 index 0000000..794cd15 --- /dev/null +++ b/app/static/admin/js/theme.js @@ -0,0 +1,56 @@ +'use strict'; +{ + window.addEventListener('load', function(e) { + + function setTheme(mode) { + if (mode !== "light" && mode !== "dark" && mode !== "auto") { + console.error(`Got invalid theme mode: ${mode}. Resetting to auto.`); + mode = "auto"; + } + document.documentElement.dataset.theme = mode; + localStorage.setItem("theme", mode); + } + + function cycleTheme() { + const currentTheme = localStorage.getItem("theme") || "auto"; + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + + if (prefersDark) { + // Auto (dark) -> Light -> Dark + if (currentTheme === "auto") { + setTheme("light"); + } else if (currentTheme === "light") { + setTheme("dark"); + } else { + setTheme("auto"); + } + } else { + // Auto (light) -> Dark -> Light + if (currentTheme === "auto") { + setTheme("dark"); + } else if (currentTheme === "dark") { + setTheme("light"); + } else { + setTheme("auto"); + } + } + } + + function initTheme() { + // set theme defined in localStorage if there is one, or fallback to auto mode + const currentTheme = localStorage.getItem("theme"); + currentTheme ? setTheme(currentTheme) : setTheme("auto"); + } + + function setupTheme() { + // Attach event handlers for toggling themes + const buttons = document.getElementsByClassName("theme-toggle"); + Array.from(buttons).forEach((btn) => { + btn.addEventListener("click", cycleTheme); + }); + initTheme(); + } + + setupTheme(); + }); +} diff --git a/app/static/admin/js/urlify.js b/app/static/admin/js/urlify.js index 61dedb2..9fc0409 100644 --- a/app/static/admin/js/urlify.js +++ b/app/static/admin/js/urlify.js @@ -163,8 +163,7 @@ s = s.replace(/^\s+|\s+$/g, ''); // trim leading/trailing spaces s = s.replace(/[-\s]+/g, '-'); // convert spaces to hyphens s = s.substring(0, num_chars); // trim to first num_chars chars - s = s.replace(/-+$/g, ''); // trim any trailing hyphens - return s; + return s.replace(/-+$/g, ''); // trim any trailing hyphens } window.URLify = URLify; } diff --git a/app/static/admin/js/vendor/jquery/LICENSE.txt b/app/static/admin/js/vendor/jquery/LICENSE.txt index e3dbacb..f642c3f 100644 --- a/app/static/admin/js/vendor/jquery/LICENSE.txt +++ b/app/static/admin/js/vendor/jquery/LICENSE.txt @@ -1,4 +1,4 @@ -Copyright JS Foundation and other contributors, https://js.foundation/ +Copyright OpenJS Foundation and other contributors, https://openjsf.org/ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/app/static/admin/js/vendor/jquery/jquery.js b/app/static/admin/js/vendor/jquery/jquery.js index 5093733..7f35c11 100644 --- a/app/static/admin/js/vendor/jquery/jquery.js +++ b/app/static/admin/js/vendor/jquery/jquery.js @@ -1,15 +1,15 @@ /*! - * jQuery JavaScript Library v3.5.1 + * jQuery JavaScript Library v3.6.4 * https://jquery.com/ * * Includes Sizzle.js * https://sizzlejs.com/ * - * Copyright JS Foundation and other contributors + * Copyright OpenJS Foundation and other contributors * Released under the MIT license * https://jquery.org/license * - * Date: 2020-05-04T22:49Z + * Date: 2023-03-08T15:28Z */ ( function( global, factory ) { @@ -23,7 +23,7 @@ // (such as Node.js), expose a factory as module.exports. // This accentuates the need for the creation of a real `window`. // e.g. var jQuery = require("jquery")(window); - // See ticket #14549 for more info. + // See ticket trac-14549 for more info. module.exports = global.document ? factory( global, true ) : function( w ) { @@ -76,12 +76,16 @@ var support = {}; var isFunction = function isFunction( obj ) { - // Support: Chrome <=57, Firefox <=52 - // In some browsers, typeof returns "function" for HTML elements - // (i.e., `typeof document.createElement( "object" ) === "function"`). - // We don't want to classify *any* DOM node as a function. - return typeof obj === "function" && typeof obj.nodeType !== "number"; - }; + // Support: Chrome <=57, Firefox <=52 + // In some browsers, typeof returns "function" for HTML elements + // (i.e., `typeof document.createElement( "object" ) === "function"`). + // We don't want to classify *any* DOM node as a function. + // Support: QtWeb <=3.8.5, WebKit <=534.34, wkhtmltopdf tool <=0.12.5 + // Plus for old WebKit, typeof returns "function" for HTML collections + // (e.g., `typeof document.getElementsByTagName("div") === "function"`). (gh-4756) + return typeof obj === "function" && typeof obj.nodeType !== "number" && + typeof obj.item !== "function"; + }; var isWindow = function isWindow( obj ) { @@ -147,7 +151,7 @@ function toType( obj ) { var - version = "3.5.1", + version = "3.6.4", // Define a local copy of jQuery jQuery = function( selector, context ) { @@ -401,7 +405,7 @@ jQuery.extend( { if ( isArrayLike( Object( arr ) ) ) { jQuery.merge( ret, typeof arr === "string" ? - [ arr ] : arr + [ arr ] : arr ); } else { push.call( ret, arr ); @@ -496,9 +500,9 @@ if ( typeof Symbol === "function" ) { // Populate the class2type map jQuery.each( "Boolean Number String Function Array Date RegExp Object Error Symbol".split( " " ), -function( _i, name ) { - class2type[ "[object " + name + "]" ] = name.toLowerCase(); -} ); + function( _i, name ) { + class2type[ "[object " + name + "]" ] = name.toLowerCase(); + } ); function isArrayLike( obj ) { @@ -518,14 +522,14 @@ function isArrayLike( obj ) { } var Sizzle = /*! - * Sizzle CSS Selector Engine v2.3.5 + * Sizzle CSS Selector Engine v2.3.10 * https://sizzlejs.com/ * * Copyright JS Foundation and other contributors * Released under the MIT license * https://js.foundation/ * - * Date: 2020-03-14 + * Date: 2023-02-14 */ ( function( window ) { var i, @@ -629,7 +633,7 @@ var i, whitespace + "+$", "g" ), rcomma = new RegExp( "^" + whitespace + "*," + whitespace + "*" ), - rcombinators = new RegExp( "^" + whitespace + "*([>+~]|" + whitespace + ")" + whitespace + + rleadingCombinator = new RegExp( "^" + whitespace + "*([>+~]|" + whitespace + ")" + whitespace + "*" ), rdescend = new RegExp( whitespace + "|>" ), @@ -846,7 +850,7 @@ function Sizzle( selector, context, results, seed ) { // as such selectors are not recognized by querySelectorAll. // Thanks to Andrew Dupont for this technique. if ( nodeType === 1 && - ( rdescend.test( selector ) || rcombinators.test( selector ) ) ) { + ( rdescend.test( selector ) || rleadingCombinator.test( selector ) ) ) { // Expand context for sibling selectors newContext = rsibling.test( selector ) && testContext( context.parentNode ) || @@ -1108,8 +1112,8 @@ support = Sizzle.support = {}; * @returns {Boolean} True iff elem is a non-HTML XML node */ isXML = Sizzle.isXML = function( elem ) { - var namespace = elem.namespaceURI, - docElem = ( elem.ownerDocument || elem ).documentElement; + var namespace = elem && elem.namespaceURI, + docElem = elem && ( elem.ownerDocument || elem ).documentElement; // Support: IE <=8 // Assume HTML when documentElement doesn't yet exist, such as inside loading iframes @@ -1170,6 +1174,24 @@ setDocument = Sizzle.setDocument = function( node ) { !el.querySelectorAll( ":scope fieldset div" ).length; } ); + // Support: Chrome 105 - 110+, Safari 15.4 - 16.3+ + // Make sure the the `:has()` argument is parsed unforgivingly. + // We include `*` in the test to detect buggy implementations that are + // _selectively_ forgiving (specifically when the list includes at least + // one valid selector). + // Note that we treat complete lack of support for `:has()` as if it were + // spec-compliant support, which is fine because use of `:has()` in such + // environments will fail in the qSA path and fall back to jQuery traversal + // anyway. + support.cssHas = assert( function() { + try { + document.querySelector( ":has(*,:jqfake)" ); + return false; + } catch ( e ) { + return true; + } + } ); + /* Attributes ---------------------------------------------------------------------- */ @@ -1436,6 +1458,17 @@ setDocument = Sizzle.setDocument = function( node ) { } ); } + if ( !support.cssHas ) { + + // Support: Chrome 105 - 110+, Safari 15.4 - 16.3+ + // Our regular `try-catch` mechanism fails to detect natively-unsupported + // pseudo-classes inside `:has()` (such as `:has(:contains("Foo"))`) + // in browsers that parse the `:has()` argument as a forgiving selector list. + // https://drafts.csswg.org/selectors/#relational now requires the argument + // to be parsed unforgivingly, but browsers have not yet fully adjusted. + rbuggyQSA.push( ":has" ); + } + rbuggyQSA = rbuggyQSA.length && new RegExp( rbuggyQSA.join( "|" ) ); rbuggyMatches = rbuggyMatches.length && new RegExp( rbuggyMatches.join( "|" ) ); @@ -1448,7 +1481,14 @@ setDocument = Sizzle.setDocument = function( node ) { // As in, an element does not contain itself contains = hasCompare || rnative.test( docElem.contains ) ? function( a, b ) { - var adown = a.nodeType === 9 ? a.documentElement : a, + + // Support: IE <9 only + // IE doesn't have `contains` on `document` so we need to check for + // `documentElement` presence. + // We need to fall back to `a` when `documentElement` is missing + // as `ownerDocument` of elements within `