diff --git a/Makefile b/Makefile index 2ba3a477..9e1854de 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -COMPOSE_FILE := docker-compose-with-celery.yml +COMPOSE_FILE := docker-compose.yml .PHONY: help help: @@ -12,7 +12,7 @@ help: .PHONY: bootstrap bootstrap: ## installs/updates all dependencies - @docker-compose --file $(COMPOSE_FILE) build --force-rm + @docker compose --file $(COMPOSE_FILE) build --force-rm .PHONY: cibuild cibuild: ## invoked by continuous integration servers to run tests @@ -22,81 +22,79 @@ cibuild: ## invoked by continuous integration servers to run tests .PHONY: console console: ## opens a console - @docker-compose run --rm web bash + @docker compose run --rm web bash .PHONY: server server: ## starts app - @docker-compose --file docker-compose.yml run --rm web python manage.py migrate --noinput - @docker-compose up + @docker compose --file docker-compose.yml run --rm web python manage.py migrate --noinput + @docker compose up .PHONY: setup setup: ## sets up a project to be used for the first time - @docker-compose --file $(COMPOSE_FILE) build --force-rm - @docker-compose --file docker-compose.yml run --rm web python manage.py migrate --noinput + @docker compose --file $(COMPOSE_FILE) build --force-rm + @docker compose --file docker-compose.yml run --rm web python manage.py migrate --noinput .PHONY: test_interrogate test_interrogate: - @docker-compose run --rm web interrogate -vv --fail-under 100 --whitelist-regex "test_.*" . + @docker compose run --rm web interrogate -vv --fail-under 100 --whitelist-regex "test_.*" . .PHONY: test_pytest test_pytest: - @docker-compose run --rm web pytest -s + @docker compose run --rm web pytest -s .PHONY: test test: test_interrogate test_pytest - @docker-compose down + @docker compose down .PHONY: update update: ## updates a project to run at its current version - @docker-compose --file $(COMPOSE_FILE) rm --force celery - @docker-compose --file $(COMPOSE_FILE) rm --force celery-beat - @docker-compose --file $(COMPOSE_FILE) rm --force web - @docker-compose --file $(COMPOSE_FILE) pull - @docker-compose --file $(COMPOSE_FILE) build --force-rm - @docker-compose --file docker-compose.yml run --rm web python manage.py migrate --noinput + @docker compose --file $(COMPOSE_FILE) rm --force celery + @docker compose --file $(COMPOSE_FILE) rm --force celery-beat + @docker compose --file $(COMPOSE_FILE) rm --force web + @docker compose --file $(COMPOSE_FILE) pull + @docker compose --file $(COMPOSE_FILE) build --force-rm + @docker compose --file docker-compose.yml run --rm web python manage.py migrate --noinput # ---- .PHONY: pip-compile pip-compile: ## rebuilds our pip requirements - @docker-compose run --rm web pip-compile ./requirements.in --output-file ./requirements.txt + @docker compose run --rm web pip-compile ./requirements.in --output-file ./requirements.txt .PHONY: build build: - docker-compose pull - DOCKER_BUILDKIT=1 docker-compose build + docker compose pull + DOCKER_BUILDKIT=1 docker compose build .PHONY: createsuperuser createsuperuser: - docker-compose run --rm web /code/manage.py createsuperuser + docker compose run --rm web /code/manage.py createsuperuser .PHONY: down down: - docker-compose down + docker compose down .PHONY: makemigrations makemigrations: @echo "Running makemigrations..." - docker-compose run --rm web /code/manage.py makemigrations + docker compose run --rm web /code/manage.py makemigrations .PHONY: migrate migrate: @echo "Running migrations..." - docker-compose run --rm web /code/manage.py migrate --noinput + docker compose run --rm web /code/manage.py migrate --noinput .PHONY: rebuild rebuild: @echo "Rebuilding local docker images..." - docker-compose kill - docker-compose rm -f web - DOCKER_BUILDKIT=1 docker-compose build --force-rm web + docker compose kill + docker compose rm -f web + DOCKER_BUILDKIT=1 docker compose build --force-rm web .PHONY: shell shell: - docker-compose run --rm web bash + docker compose run --rm web bash .PHONY: up up: - docker-compose up -d - - + docker compose up -d diff --git a/README.md b/README.md index f35d3c37..acfce84e 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,15 @@ ## Overview -A Django based website that will power https://boost.org +A Django based website that will power a new Boost website + +--- ## Local Development Setup -This project will use Python 3.11, Docker, and Docker Compose. +This project will use Python 3.11, Docker, and Docker Compose. + +Instructions to install those packages are included in [development_setup_notes.md](docs/development_setup_notes.md). **NOTE**: All of these various `docker compose` commands, along with other helpful developer utility commands, are codified in our `justfile` and can be ran with @@ -14,7 +18,7 @@ less typing. You will need to install `just`, by [following the documentation](https://just.systems/man/en/) -Copy file `env.template` to `.env` and adjust values to match your local environment: +**Environment Variables**: Copy file `env.template` to `.env` and adjust values to match your local environment. See [Environment Variables](docs/env_vars.md) for more information. ```shell $ cp env.template .env @@ -42,6 +46,8 @@ $ docker compose run --rm web python manage.py migrate This will create the Docker image, install dependencies, start the services defined in `docker-compose.yml`, and start the webserver. +Access the site at http://localhost:8000 + ### Cleaning up To shut down our database and any long running services, we shut everyone down using: @@ -50,10 +56,6 @@ To shut down our database and any long running services, we shut everyone down u $ docker compose down ``` -## Environment Variables - -See [Environment Variables](docs/env_vars.md) for more information on environment variables. - ## Running the tests To run the tests, execute: @@ -88,21 +90,31 @@ For production, execute: $ yarn build ``` -## Generating Fake Data +--- -### Versions and LibraryVersions +## Generating Local Data -First, make sure your `GITHUB_TOKEN` is set in you `.env` file and run `./manage.py update_libraries`. This takes a long time. See below. +### Sample (Fake) Data -Run `./manage.py generate_fake_versions`. This will create 50 active Versions, and associate Libraries to them. +To **remove all existing data** (except for superusers) from your local project's database (which is in its own Docker volume) and generate fresh sample data **that will not sync with GitHub**, run: -The data created is realistic-looking in that each Library will contain a M2M relationship to every Version newer than the oldest one it's included in. (So if a Library's earliest LibraryVersion is 1.56.0, then there will be a LibraryVersion object for that Library for each Version since 1.56.0 was released.) +```bash +./manage.py create_sample_data --all +``` -This does not add VersionFile objects to the Versions. +For more information on the many, many options available for this command, see `create_sample_data` in [Management Commands](docs/commands.md). -### Libraries, Pull Requests, and Issues +### Live GitHub Libraries -There is not currently a way to generate fake Libraries, Issues, or Pull Requests. To generate those, use your GitHub token and run `./manage.py update_libraries` locally to pull in live GitHub data. This command takes a long time to run; you might consider editing `libraries/github.py` to add counters and breaks to shorten the runtime. +To **add real Boost libraries and sync all the data from GitHub**, run: + +```bash +./manage.py update_libraries +``` + +This command can take a long time to run (about a half hour). For more information, see `update_libraries` in [Management Commands](docs/commands.md). + +--- ## Deploying @@ -112,14 +124,35 @@ TDB TDB +--- -## Pre-commit +## Pre-commit Hooks -Pre-commit is configured for the following +We use [pre-commit hooks](https://pre-commit.com/) to check code for style, syntax, and other issues. They help to maintain consistent code quality and style across the project, and prevent issues from being introduced into the codebase. -* Black -* Ruff -* Djhtml for cleaning up django templates -* Rustywind for sorting tailwind classes +| Pre-commit Hook | Description | +| --------------- | ----------- | +| [Black](https://github.com/psf/black) | Formats Python code using the `black` code formatter | +| [Ruff](https://github.com/charliermarsh/ruff) | Wrapper around `flake8` and `isort`, among other linters | +| [Djhtml](https://github.com/rtts/djhtml) | Auto-formats Django templates | +| [Rustywind](https://github.com/avencera/rustywind) | Sorts and formats Tailwind CSS classes | -Add the hooks by executing `pre-commit install` +### Setup and Usage + +| Description | Command | +| ---- | ------- | +| 1. Install the `pre-commit` package using `pip` | `pip install pre-commit` | +| 2. Install our list of pre-commit hooks locally | `pre-commit install` | +| 3. Run all hooks for changed files before commit | `pre-commit run` | +| 4. Run specific hook before commit | `pre-commit run {hook}` | +| 5. Run hooks for all files, even unchanged ones | `pre-commit run --all-files` | +| 6. Commit without running pre-commit hooks | `git commit -m "Your commit message" --no-verify` | + +Example commands for running specific hooks: + +| Hook | Example | +| --------------- | --------------- | +| Black | `pre-commit run black` | +| Ruff | `pre-commit run ruff` | +| Djhtml | `pre-commit run djhtml` | +| Rustywind | `pre-commit run rustywind` | diff --git a/config/celery.py b/config/celery.py index 2792fc4c..70cf751b 100644 --- a/config/celery.py +++ b/config/celery.py @@ -14,10 +14,10 @@ app = Celery("config") # should have a `CELERY_` prefix. app.config_from_object("django.conf:settings", namespace="CELERY") -# Load task modules from all registered Django app configs. +# Load task modules from all registered Django apps. app.autodiscover_tasks() @app.task(bind=True) def debug_task(self): - print("Request: {0!r}".format(self.request)) + print(f"Request: {self.request!r}") diff --git a/config/settings.py b/config/settings.py index 16a60432..5713c22e 100755 --- a/config/settings.py +++ b/config/settings.py @@ -88,7 +88,14 @@ INSTALLED_APPS += [ ] # Our Apps -INSTALLED_APPS += ["ak", "users", "versions", "libraries", "mailing_list"] +INSTALLED_APPS += [ + "ak", + "users", + "versions", + "libraries", + "mailing_list", + "news", +] AUTH_USER_MODEL = "users.User" CSRF_COOKIE_HTTPONLY = True @@ -271,8 +278,8 @@ CACHES = { "BACKEND": "django_redis.cache.RedisCache", "LOCATION": f"redis://{REDIS_HOST}:6379/2", "TIMEOUT": env( - "STATIC_CACHE_TIMEOUT", default=86400 - ), # Cache timeout in seconds: 1 day + "STATIC_CACHE_TIMEOUT", default="60" + ), # Cache timeout in seconds: 1 minute }, } diff --git a/config/urls.py b/config/urls.py index 99afe668..99123f3b 100755 --- a/config/urls.py +++ b/config/urls.py @@ -28,11 +28,16 @@ from libraries.views import ( LibraryByCategory, LibraryDetail, LibraryListByVersion, - LibraryDetailByVersion, LibraryListByVersionByCategory, ) from libraries.api import LibrarySearchView from mailing_list.views import MailingListView, MailingListDetailView +from news.views import ( + EntryApproveView, + EntryCreateView, + EntryDetailView, + EntryListView, +) from support.views import SupportView, ContactView from versions.api import VersionViewSet from versions.views import VersionList, VersionDetail @@ -47,12 +52,6 @@ router.register(r"libraries", LibrarySearchView, basename="libraries") urlpatterns = ( [ path("", HomepageView.as_view(), name="home"), - # scratch template for design scrums - path( - "scratch/", - TemplateView.as_view(template_name="scratch.html"), - name="scratch", - ), path("admin/", admin.site.urls), path("accounts/", include("allauth.urls")), path( @@ -81,6 +80,18 @@ urlpatterns = ( ), path("health/", include("health_check.urls")), path("forum/", include(machina_urls)), + # temp page for community until mailman is done. + path( + "community/", + TemplateView.as_view(template_name="community_temp.html"), + name="community", + ), + # temp page for releases + path( + "releases/", + TemplateView.as_view(template_name="releases_temp.html"), + name="releases", + ), path( "donate/", TemplateView.as_view(template_name="donate/donate.html"), @@ -103,6 +114,12 @@ urlpatterns = ( name="mailing-list-detail", ), path("mailing-list/", MailingListView.as_view(), name="mailing-list"), + path("news/", EntryListView.as_view(), name="news"), + path("news/add/", EntryCreateView.as_view(), name="news-create"), + path("news//", EntryDetailView.as_view(), name="news-detail"), + path( + "news//approve/", EntryApproveView.as_view(), name="news-approve" + ), path( "people/detail/", TemplateView.as_view(template_name="boost/people_detail.html"), @@ -153,16 +170,6 @@ urlpatterns = ( TemplateView.as_view(template_name="review/review_process.html"), name="review-process", ), - path( - "news/detail/", - TemplateView.as_view(template_name="news/news_detail.html"), - name="news_detail", - ), - path( - "news/", - TemplateView.as_view(template_name="news/news_list.html"), - name="news", - ), # support and contact views path("support/", SupportView.as_view(), name="support"), path( @@ -184,7 +191,7 @@ urlpatterns = ( ), path( "versions///", - LibraryDetailByVersion.as_view(), + LibraryDetail.as_view(), name="library-detail-by-version", ), path("versions//", VersionDetail.as_view(), name="version-detail"), diff --git a/conftest.py b/conftest.py index fee10029..74360270 100644 --- a/conftest.py +++ b/conftest.py @@ -8,6 +8,7 @@ from django.core.files import File as DjangoFile # directories pytest_plugins = [ "libraries.tests.fixtures", + "news.tests.fixtures", "users.tests.fixtures", "versions.tests.fixtures", ] diff --git a/core/boostrenderer.py b/core/boostrenderer.py index 463f0010..e53920fa 100644 --- a/core/boostrenderer.py +++ b/core/boostrenderer.py @@ -50,6 +50,11 @@ def get_content_from_s3(key=None, bucket_name=None): response = client.get_object(Bucket=bucket_name, Key=s3_key.lstrip("/")) file_content = response["Body"].read() content_type = response["ContentType"] + + # Check if the file ends with '.js', if yes then set the content_type to 'application/javascript' + if s3_key.endswith(".js"): + content_type = "application/javascript" + logger.info( "get_content_from_s3_success", key=key, diff --git a/core/views.py b/core/views.py index dd06c550..dc5d2426 100644 --- a/core/views.py +++ b/core/views.py @@ -121,7 +121,11 @@ class StaticContentTemplateView(View): content, content_type = result # Store the result in cache - static_content_cache.set(cache_key, (content, content_type)) + static_content_cache.set( + cache_key, + (content, content_type), + int(settings.CACHES["static_content"]["TIMEOUT"]), + ) response = HttpResponse(content, content_type=content_type) logger.info( diff --git a/docker-compose.yml b/docker-compose.yml index 77a83805..03a69034 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,11 +48,7 @@ services: mailman-core: image: maxking/mailman-core - stop_grace_period: 30s - env_file: - - .env - depends_on: - - db + stop_grace_period: 5s ports: - "8001:8001" # API - "8024:8024" # LMTP - incoming emails @@ -60,6 +56,29 @@ services: - ./mailman/core:/opt/mailman/ networks: - backend + env_file: + - .env + depends_on: + - db + + celery-worker: + build: + context: . + dockerfile: docker/Dockerfile + command: + [ + "celery", + "-A", + "config", + "worker", + "--concurrency=10", + "--loglevel=debug" + ] + env_file: + - .env + depends_on: + - db + - redis mailman-web: image: maxking/mailman-web @@ -68,10 +87,14 @@ services: - "DOCKER_DIR=/opt/mailman-docker" - "PYTHON=python3" - "WEB_PORT=8008" - env_file: - - .env depends_on: + - redis - db + networks: + - backend + volumes: + - .:/code + stop_signal: SIGKILL ports: - "8008:8008" # HTTP - "8080:8080" # uwsgi @@ -80,6 +103,29 @@ services: - ./docker:/opt/mailman-docker networks: - backend + + celery-beat: + build: + context: . + dockerfile: docker/Dockerfile + command: + [ + "celery", + "-A", + "config", + "beat", + "--loglevel=debug" + ] + env_file: + - .env + depends_on: + - db + - redis + networks: + - backend + volumes: + - .:/code + stop_signal: SIGKILL networks: diff --git a/docs/commands.md b/docs/commands.md index f6703ad1..ce46ac91 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1,26 +1,26 @@ -# Management Commands +# Management Commands ## `create_sample_data` Running this command will populate the database with fake data for local development. -When run, it will create fake objects for these models: +When run, it will create fake objects for these models: -- User +- User - Version -- Category +- Category - Library - LibraryVersion -- Authors for Libraries and Maintainers for LibraryVersions -- Issues and Pull Requests for Libraries +- Authors for Libraries and Maintainers for LibraryVersions +- Issues and Pull Requests for Libraries -The data generated is fake. Any links, information that looks like it comes from GitHub, email addresses, etc. is all fake. Some of it is made to look like realistic data. +The data generated is fake. Any links, information that looks like it comes from GitHub, email addresses, etc. is all fake. Some of it is made to look like realistic data. The following options can be used with the command: - `--all`: If True, run all methods including the drop command. -If you don't want to drop all records for the above models and create a new set of fresh data, you can pass these options to clear your database or and create new records. +If you don't want to drop all records for the above models and create a new set of fresh data, you can pass these options to clear your database or and create new records. - `--drop`: If True, drop all records in the database. - `--users`: If True, create fake users. @@ -37,7 +37,7 @@ If you don't want to drop all records for the above models and create a new set ./manage.py create_sample_data --all -Output: +Output: Dropping all records... Dropping Non-Superusers... @@ -68,11 +68,11 @@ Output: ...10 issues created for algorithm -### Example: Create new pull requests and issues for existing library objects +### Example: Create new pull requests and issues for existing library objects - ./manage.py create_sample_data --prs --issues + ./manage.py create_sample_data --prs --issues -Output: +Output: Adding library pull requests... ...9 pull requests created for algorithm @@ -82,30 +82,101 @@ Output: ...10 issues created for asio -## `generate_fake_versions` +## `generate_fake_versions` -Creates fake Version objects **only**, then creates LibraryVersion objects for each existing Library and the new Versions. +Creates fake Version objects **only**, then creates LibraryVersion objects for each existing Library and the new Versions. -### Example: +### Example: ./manage.py generate_fake_versions -Output: +Output: Version 1.30.0 created succcessfully ---algorithm (1.30.0) created succcessfully +## `import_library_versions` + +Connect Library objects to the Boost versions (AKA "release") that included them using information from the main Boost GitHub repo and the library repos. Functions of this command: + +- Prints out any versions or libraries that were skipped at the end. +- Idempotent. + +**Options** + +Here are the options you can use: + +- `--release`: Full or partial Boost version (release) number. If `release` is passed, the command will import all libraries for the versions that contain the passed-in release number. If not passed, the command will import libraries for all active versions. +- `--token`: Pass a GitHub API token. If not passed, will use the value in `settings.GITHUB_TOKEN`. + +### Example: + + ./manage.py import_library_versions + +Output: + + Saved library version Log (boost-1.16.1). + Processing version boost-0.9.27... + Processing module python... + Saved library version Python (boost-0.9.27). Created? False + User stefan@seefeld.name added as a maintainer of Python (boost-0.9.27) + {"message": "User username added as a maintainer of Python (boost-0.9.27)", "logger": "libraries.github", "level": "info", "timestamp": "2023-05-17T21:24:39.046029Z"} + Updated maintainers for Python (boost-0.9.27). + Saved library version Python (boost-0.9.27). + Skipped disjoint_sets in boost-1.57.0: Could not find library in database by gitmodule name + Skipped signals in boost-1.57.0: Could not find library in database by gitmodule name + + +## `import_versions` + +Import Boost version (AKA "release") information from the Boost GitHub repo. Functions of this command: + +- **Retrieves Boost tags**: It collects all the Boost tags from the main Github repo, excluding beta releases and release candidates. For each tag, it gathers the associated data. If it's a full release, the data is in the tag; otherwise, the data is in the commit. +- **Updates local database**: For each tag, it creates or updates a Version instance in the local database. +- **Options for managing versions and library versions**: The command provides options to delete existing versions and library versions, and to create new library versions for the most recent Boost version. +- Idempotent. + +**Options** + +Here are the options you can use: + +- `--delete-versions`: Deletes all existing Version instances in the database before importing new ones. +- `--delete-library-versions`: Deletes all existing LibraryVersion instances in the database before importing new ones. +- `--create-recent-library-versions`: Creates a LibraryVersion for each active Boost library and the most recent Boost version. +- `--skip-existing-versions`: If a Version exists in the database (by name), skip calling the GitHub API for more information on it. +- `--token`: Pass a GitHub API token. If not passed, will use the value in `settings.GITHUB_TOKEN`. + + +### Example: + + ./manage.py import_versions + +Output: + + Saved version boost-1.82.0. Created: True + Skipping boost-1.82.0.beta1, not a full release + Saved version boost-1.81.0. Created: True + Skipping boost-1.81.0.beta1, not a full release + tag_not_found + {"message": "tag_not_found", "tag_name": "boost-1.80.0", "repo_slug": "boost", "logger": "libraries.github", "level": "info", "timestamp": "2023-05-12T22:14:08.721270Z"} + ... + Saved library version Math (boost-1.82.0). Created: True + Saved library version Xpressive (boost-1.82.0). Created: True + Saved library version Dynamic Bitset (boost-1.82.0). Created: True + Saved library version Multi-Index (boost-1.82.0). Created: True + ... + ## `update_libraries` -Runs the library update script, which cycles through the repos listed in the Boost library and syncs their information. +Runs the library update script, which cycles through the repos listed in the Boost library and syncs their information. -Synced information: +Synced information: -- Most library information comes from `meta/libraries.json` stored in each Boost library repo -- Library data and metadata from GitHub is saved to our database -- Categories are updated, if needed -- Library categories are updated, if need be. -- Issues and Pull Requests are synced +- Most library information comes from `meta/libraries.json` stored in each Boost library repo +- Library data and metadata from GitHub is saved to our database +- Categories are updated, if needed +- Library categories are updated, if need be. +- Issues and Pull Requests are synced -**NOTE**: Can take upwards of a half hour to run. If you are trying to populate tables for local development, `create_sample_data` is a better option if the GitHub integrations aren't important. +**NOTE**: Can take upwards of a half hour to run. If you are trying to populate tables for local development, `create_sample_data` is a better option if the GitHub integrations aren't important. diff --git a/docs/development_setup_notes.md b/docs/development_setup_notes.md new file mode 100644 index 00000000..5c8ec0db --- /dev/null +++ b/docs/development_setup_notes.md @@ -0,0 +1,101 @@ +## Development Setup Notes + +The procedure to configure a development environment is mainly covered in the top-level README.md. This document will contain more details about installing prerequisites: Just, Python 3.11, Docker, and Docker Compose. + +- [Windows](#Windows) +- [Ubuntu 22.04](#ubuntu-2204) + + +## Windows + +(Tested on: Windows 2022 Server) + +In Powershell, install WSL: +``` +wsl --install +Restart-Computer +``` + +After rebooting, open Powershell (wait a minute, it may continue installing WSL). If the installation hasn't completed for some reason, `wsl --install` again. After WSL and an Ubuntu distribution have been installed, log in to WSL: +``` +wsl +``` + +When running the Django website, everything should be done from a WSL session, not Powershell, DOS, or git-bash. Do not `git clone` the files in a DOS window, for example. However, once it's up and running, files may be edited elsewhere. The file path in explorer will be `\\wsl.localhost\Ubuntu\opt\github\cppalliance\temp-site` + +Continue to the [Ubuntu 22.04 instructions](#ubuntu-2204) below. Return here before executing `docker compose`. + +The docker daemon must be launched manually. Open a WSL window and keep it running. Otherwise there will be an error message "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?" + +``` +sudo dockerd +``` + +Open another terminal: +``` +wsl +``` + +Continue (as root) to the instructions in the top-level README.md file. + +## Ubuntu 22.04 + +Check if python 3.11 is installed. +``` +python3 --version +``` + +or install python 3.11: +``` +sudo add-apt-repository ppa:deadsnakes/ppa +sudo apt-get update +sudo apt-get install -y python3.11 +``` + +Install `makedeb` (as a standard user, not root). +``` +bash -ci "$(wget -qO - 'https://shlink.makedeb.org/install')" +``` + +Install `just` (as a standard user, not root). + +``` +sudo mkdir -p /opt/justinstall +CURRENTUSER=$(whoami) +sudo chown $CURRENTUSER /opt/justinstall +chmod 777 /opt/justinstall +cd /opt/justinstall +git clone 'https://mpr.makedeb.org/just' +cd just +makedeb -si +``` + +Install docker and docker-compose. +``` +sudo apt-get update +sudo apt-get install -y ca-certificates curl gnupg +sudo install -m 0755 -d /etc/apt/keyrings +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg +sudo chmod a+r /etc/apt/keyrings/docker.gpg +echo \ + "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + "$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \ + sudo tee /etc/apt/sources.list.d/docker.list > /dev/null +sudo apt-get update +sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin +``` + +As root, clone the repository and switch to that directory. +``` +sudo su - +mkdir -p /opt/github/cppalliance +cd /opt/github/cppalliance +git clone https://github.com/cppalliance/temp-site +cd temp-site +cp env.template .env +``` + +Continue (as the root user) to the instructions in the top-level README.md file. Or if using WSL, review the last few steps in that section again. + +The advantage of running `docker compose` as root is the userid (0) will match the containers and the shared files. + diff --git a/docs/syncing_data_with_github.md b/docs/syncing_data_with_github.md new file mode 100644 index 00000000..1fef691a --- /dev/null +++ b/docs/syncing_data_with_github.md @@ -0,0 +1,167 @@ +# Syncing Data about Boost Versions and Libraries with GitHub + +## About + +The data in our database generally originates from somewhere in the Boost GitHub ecosystem. + +This page will explain to Django developers how data is synced from GitHub to our database. + +- Most code is in `libraries/github.py` and `libraries/tasks.py` + +## Release data + +- Releases are also called "Versions." +- The model that saves Release/Version data is `versions/models.py::Version` +- We retrieve all the non-beta and non-release-candidate tags from the main Boost repo + +Boost releases some tags as formal GitHub "releases," and these show up on the **Releases** tab. + +Not all tags are official GitHub **Releases**, however, and this impacts where we get metadata about the tag. + +To retrieve releases and tags, run: + +```bash +./manage.py import_releases +``` + +This will: + +- Delete existing Versions and LibraryVersions +- Retrieve tags and releases from the Boost GitHub repo +- Create new Versions for each tag and release that is not a beta or rc release +- Create a new LibraryVersion for each Library **but not for historical versions** + + +## Library data + +- Once a month, the task `libraries/tasks/update_libraries()` runs. +- It cycles through all Boost libraries and updates data +- **It only handles the most recent version of Boost** and does not handle older versions yet. +- There are methods to download issues and PRs, but **the methods to download issues and PRs are not currently called**. + +### Tasks or Questions + +- [ ] A new GitHub API needs to be generated through the CPPAlliance GitHub organization, and be added as a kube secret +- [ ] `self.skip_modules`: This exists in both `GitHubAPIClient` and `LibraryUpdater` but it should probably only exist in `LibraryUpdater`, to keep `GitHubAPIClient` less tightly coupled to specific repos +- [ ] If we only want aggregate data for issues and PRs, do we need to save the data from them in models, or can we just save the aggregate data somewhere? + +### Glossary + +To make the code more readable to the Boost team, who will ultimately maintain the project, we tried to replicate their terminology as much as possible. + +- Library: Boost “Libraries” correspond to GitHub repositories +- `.gitmodules`: The file in the main Boost project repo that contains the information on all the repos that are considered Boost libraries +- module and submodule: Other words for library that correspond more specifically to GitHub data + +--- + +### How it Works + +#### `LibraryUpdater` + +_This is not a code walkthrough, but is a general overview of the objects and data that this class retrieves._ + +- The Celery task `libraries/tasks.py/update_libraries` runs `LibraryUpdater.update_libraries()` +- This class uses the `GitHubAPIClient` class to call the GitHub API +- It retrieves the list of libraries to update from the `.gitmodules` file in the [main Boost repo](https://github.com/boostorg/boost): [https://github.com/boostorg/boost/blob/master/.gitmodules](https://github.com/boostorg/boost/blob/master/.gitmodules) +- From that list, it makes sure to exclude any libraries in `self.skip_modules`. The modules in `self.skipped_submodules` are not imported into the database. +- For each remaining library: + - It uses the information from the `.gitmodules` file to call the GitHub API for that specific library + - It downloads the `meta/libraries.json` file for that library and parses that data + - It uses the parsed data to add or update the Library record in our database for that GitHub repo + - It adds the library to the most recent Version object to create a LibraryVersion record, if needed + - The library categories are updated + - The maintainers are updated and stub Users are added for them if needed. + - The authors are updated and stub Users are added for them if needed (updated second because maintainers are more likely to have email addresses, so matching is easier). + +#### `GithubAPIClient` + +- This class controls the requests to and responses from the GitHub API. Mostly a wrapper around `GhApi` that allows us to set some default values to make calling the methods easier, and allows us to retrieve some data that is very specific to the Boost repos +- Requires the environment variable `GITHUB_TOKEN` to be set +- Contains methods to retrieve the `.gitmodules` file, retrieve the `.libraries.json` file, general repo data, repo issues, repo PRs, and the git tree. + +#### `GithubDataParser` + +- Contains methods to parse the data we retrieve from GitHub into more useful formats +- Contains methods to parse the `.gitmodules` file and the `libraries.json` file, and to extract the author and maintainer names and email addresses, if present. + +**Attributes** + +| owner | GitHub repo owner | boostorg | +| --- | --- | --- | +| ref | GitHub branch or tag to use on that repo | heads/master | +| repo_slug | GitHub repo slug | default | + +- `self.skip_modules`: This is the list of modules/libraries from `.gitmodules` that we do not download + +--- + +### GitHub Data + +- Each Boost Library has a GitHub repo. +- Most of the time, one library has one repo. Other times, one GitHub repo is shared among multiple libraries (the “Algorithm” library is an example). +- The most important file for each Boost library is `meta/libraries.json` + +#### `.gitmodules` + +This is the most important file in the main Boost repository. It contains the GitHub information for all Libraries included in that tagged Boost version, and is what we use to identify which Libraries to download into our database. + +- `submodule`: Corresponds to the `key` in `libraries.json` +- Contains information for the top-level Library, but not other sub-libraries stored in the same repo +- `path`: the path to navigate to the Library repo from the main Boost repo +- `url`: the URL for the `.git` repo for the library, in relative terms (`../system.git`) +- `fetchRecurseSubmodules`: We don’t use this field +- `branch`: We don’t use this field + +Screenshot 2023-05-08 at 12 32 32 PM + +#### `libraries.json` + +This is the most important file in the GitHub repo for a library. It is where we retrieve all the metadata about the Library. It is the source of truth. + +- `key`: The GitHub slug, and the slug we use for our Library object + - When the repo hosts a single Library, the `key` corresponds to the `submodule` in the main Boost repo’s `libraries.json` file. Example: `"key": "asio"` + - When the repo hosts multiple libraries, the **first** `key` corresponds to the `submodule`. Example: `"key": "algorithm"`. Then, the **following** keys in `libraries.json` will be prefixed with the original `key` before adding their own slug. Example: `"key": "algorithm/minimax"` +- `name`: What we save as the Library name +- `authors`: A list of names of original authors of the Library’s documentation. + - Data is very unlikely to change + - Data generally does not contain emails + - Stub users are creates for authors with fake email addresses and users will be able to claim those accounts. +- `description`: What we save as the `Library` description +- `category`: A list of category names. We use this to attach Categories to the Libraries. +- `maintainers`: A list of names and emails of current maintainers of this Library + - Data may change between versions + - Data generally contains emails + - Stub users are created for all maintainers. We use fake email addresses if an email address is not present + - We try to be smart — if the same name shows up as an author and a maintainer, we won’t create two fake records. But it’s imperfect. +- `cxxstd`: C++ version in which this Library was added + +Example with a single library: + +Screenshot 2023-05-08 at 12 25 59 PM + +Example with multiple libraries: + +Screenshot 2023-05-08 at 12 25 30 PM + +--- + +## General Maintenance Notes + +### How to change the skipped libraries + +- To **add a new skipped submodule**: add the name of the submodule to the list `self.skipped_modules` and make a PR. This will not remove the library from the database, but it will stop refreshing data for that library. +- To **remove a submodule that is currently being skipped**: remove the name of the submodule from `self.skipped_modules` and make a PR. The library will be added to the database the next time the update runs. + +### How to delete Libraries + +- Via the Admin. The Library update process does not delete any records. + +### How to add new Categories + +- They will be **automatically added** as part of the download process as soon as they are added to a library's `libraries.json` file. + +### How to remove authors or maintainers + +- Via the Admin. +- But if they are not also removed from the `libraries.json` file for the affected library, then they will be added back the next time the job runs. diff --git a/env.template b/env.template index a515f456..5729fef5 100644 --- a/env.template +++ b/env.template @@ -18,7 +18,8 @@ GITHUB_TOKEN="top-secret" AWS_ACCESS_KEY_ID="changeme" AWS_SECRET_ACCESS_KEY="changeme" -BUCKET_NAME="stage.boost.org" +BUCKET_NAME="boost.revsys.dev" + # Mailman database settings DATABASE_URL="postgresql://postgres@db:5432/postgres" @@ -28,3 +29,7 @@ HYPERKITTY_API_KEY="changeme!" MAILMAN_ADMIN_USER="" MAILMAN_ADMIN_EMAIL="" SERVE_FROM_DOMAIN=localhost + +CELERY_BROKER=redis://redis:6379/0 +CELERY_BACKEND=redis://redis:6379/0 + diff --git a/frontend/styles.css b/frontend/styles.css index f1b854c4..6bc54625 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -1,7 +1,6 @@ @tailwind base; @tailwind components; @tailwind utilities; -@import url("https://fonts.googleapis.com/css2?family=Cairo:wght@200;300;400;500;600;700;800;900&display=swap"); @layer base { [x-cloak] { @@ -59,4 +58,24 @@ #authpages #footerSignup { @apply hidden; } + /* Dark mode scrollbar */ + .dark ::-webkit-scrollbar { + @apply bg-gray-600; + } + .dark ::-webkit-scrollbar-track { + @apply bg-gray-700; + } + .dark ::-webkit-scrollbar-thumb { + @apply bg-gray-600 w-5 h-5; + } + .dark ::-webkit-scrollbar-button:start:decrement { + @apply bg-gray-700; + background-image: url('data:image/svg+xml;charset=UTF-8,'); + @apply object-center; + } + .dark ::-webkit-scrollbar-button:end:increment { + @apply bg-gray-700; + background-image: url('data:image/svg+xml;charset=UTF-8,'); + @apply object-center + } } diff --git a/justfile b/justfile index c751228d..1fc5b719 100644 --- a/justfile +++ b/justfile @@ -1,5 +1,5 @@ set dotenv-load := false -COMPOSE_FILE := "docker-compose-with-celery.yml" +COMPOSE_FILE := "docker-compose.yml" ENV_FILE := ".env" @_default: @@ -19,13 +19,13 @@ bootstrap: ## installs/updates all dependencies cp env.template {{ENV_FILE}} fi - # docker-compose --file $(COMPOSE_FILE) build --force-rm + # docker compose --file $(COMPOSE_FILE) build --force-rm rebuild: - docker-compose rm -f celery || true - docker-compose rm -f celery-beat || true - docker-compose rm -f web - docker-compose build --force-rm web + docker compose rm -f celery || true + docker compose rm -f celery-beat || true + docker compose rm -f web + docker compose build --force-rm web @cibuild: ## invoked by continuous integration servers to run tests python -m pytest @@ -34,36 +34,44 @@ rebuild: alias shell := console @console: ## opens a console - docker-compose run --rm web bash + docker compose run --rm web bash @server: ## starts app - docker-compose --file docker-compose.yml run --rm web python manage.py migrate --noinput - docker-compose up + docker compose --file docker-compose.yml run --rm web python manage.py migrate --noinput + docker compose up @setup: ## sets up a project to be used for the first time - docker-compose --file $(COMPOSE_FILE) build --force-rm - docker-compose --file docker-compose.yml run --rm web python manage.py migrate --noinput + docker compose --file $(COMPOSE_FILE) build --force-rm + docker compose --file docker-compose.yml run --rm web python manage.py migrate --noinput @test_pytest: - -docker-compose run --rm web pytest -s + -docker compose run --rm web pytest -s @test: just test_pytest - docker-compose down + docker compose down @coverage: - docker-compose run --rm web pytest --cov=. --cov-report=html + docker compose run --rm web pytest --cov=. --cov-report=html open htmlcov/index.html @update: ## updates a project to run at its current version - docker-compose --file $(COMPOSE_FILE) rm --force celery - docker-compose --file $(COMPOSE_FILE) rm --force celery-beat - docker-compose --file $(COMPOSE_FILE) rm --force web - docker-compose --file $(COMPOSE_FILE) pull - docker-compose --file $(COMPOSE_FILE) build --force-rm - docker-compose --file docker-compose.yml run --rm web python manage.py migrate --noinput + docker compose --file $(COMPOSE_FILE) rm --force celery + docker compose --file $(COMPOSE_FILE) rm --force celery-beat + docker compose --file $(COMPOSE_FILE) rm --force web + docker compose --file $(COMPOSE_FILE) pull + docker compose --file $(COMPOSE_FILE) build --force-rm + docker compose --file docker-compose.yml run --rm web python manage.py migrate --noinput + +@down: ## stops a project + docker compose down # ---- -@pip-compile: ## rebuilds our pip requirements - docker-compose run --rm web pip-compile ./requirements.in --output-file ./requirements.txt +# Compile new python dependencies +@pip-compile ARGS='': ## rebuilds our pip requirements + docker compose run --rm web pip-compile {{ ARGS }} ./requirements.in --output-file ./requirements.txt + +# Upgrade existing Python dependencies to their latest versions +@pip-compile-upgrade: + just pip-compile --upgrade diff --git a/kube/boost/Chart.yaml b/kube/boost/Chart.yaml index bee64440..d7301dc6 100644 --- a/kube/boost/Chart.yaml +++ b/kube/boost/Chart.yaml @@ -1,4 +1,4 @@ apiVersion: v1 -description: boost.org website +description: boost.revsys.dev website name: boost version: v1.0.3 diff --git a/kube/boost/values.yaml b/kube/boost/values.yaml index 2b6a8dd9..35d69058 100644 --- a/kube/boost/values.yaml +++ b/kube/boost/values.yaml @@ -119,6 +119,9 @@ Env: secretKeyRef: name: static-content key: bucket_name + # Static content cache timeout + - name: STATIC_CACHE_TIMEOUT + value: "60" # Volumes Volumes: diff --git a/libraries/forms.py b/libraries/forms.py index fa78bb41..f706c314 100644 --- a/libraries/forms.py +++ b/libraries/forms.py @@ -1,8 +1,18 @@ -from django.forms import ModelForm -from .models import Library, Category +from django.forms import Form, ModelChoiceField, ModelForm + +from versions.models import Version +from .models import Library class LibraryForm(ModelForm): class Meta: model = Library fields = ["categories"] + + +class VersionSelectionForm(Form): + version = ModelChoiceField( + queryset=Version.objects.all(), + label="Select a version", + empty_label="Choose a version...", + ) diff --git a/libraries/github.py b/libraries/github.py index 4436f05d..699011ba 100644 --- a/libraries/github.py +++ b/libraries/github.py @@ -1,9 +1,10 @@ import base64 import os import re +from datetime import datetime + import requests import structlog - from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.core.validators import validate_email @@ -11,6 +12,7 @@ from fastcore.xtras import obj2dict from ghapi.all import GhApi, paged from versions.models import Version + from .models import Category, Issue, Library, LibraryVersion, PullRequest from .utils import generate_fake_email, parse_date @@ -27,6 +29,7 @@ class GithubAPIClient: owner: str = "boostorg", ref: str = "heads/master", repo_slug: str = "boost", + token: str = None, ) -> None: """ Initialize the GitHubAPIClient. @@ -35,7 +38,7 @@ class GithubAPIClient: :param ref: str, the Git reference :param repo_slug: str, the repository slug """ - self.api = self.initialize_api() + self.api = self.initialize_api(token=token) self.owner = owner self.ref = ref self.repo_slug = repo_slug @@ -83,43 +86,105 @@ class GithubAPIClient: owner=self.owner, repo=repo_slug, file_sha=file_sha ) - def get_gitmodules(self, repo_slug: str = None) -> str: + def get_commit_by_sha(self, repo_slug: str = None, commit_sha: str = None) -> dict: + """Get a commit by its SHA.""" + if not repo_slug: + repo_slug = self.repo_slug + return self.api.git.get_commit( + owner=self.owner, repo=repo_slug, commit_sha=commit_sha + ) + + def get_first_tag(self, repo_slug: str = None): """ - Get the .gitmodules file for the repo from the GitHub API. + Retrieves the earliest tag in the repo. :param repo_slug: str, the repository slug - :return: str, the .gitmodules file + :return: tuple with GitHub tag object, commit date. + - See https://docs.github.com/en/rest/git/tags for tag object format. """ if not repo_slug: repo_slug = self.repo_slug - ref = self.get_ref() + try: + per_page = 100 + page = 1 + all_tags = [] + + while True: + tags = self.api.repos.list_tags( + owner=self.owner, repo=repo_slug, per_page=per_page, page=page + ) + all_tags.extend(tags) + if len(tags) < per_page: # End of results + break + + page += 1 # Go to the next page + + # Sort the tags by the commit date. The first tag will be the earliest. + # The Github API doesn't return the commit date with the tag, so we have to retrieve each + # one individually. This is slow, but it's the only way to get the commit date. + def get_tag_commit_date(tag): + """Get the commit date for a tag. + + For commit format, see + https://docs.github.com/en/rest/commits/commits.""" + commit_sha = tag["commit"]["sha"] + commit = self.get_commit_by_sha(repo_slug, commit_sha) + return commit["committer"]["date"] + + annotated_tags = [(tag, get_tag_commit_date(tag)) for tag in all_tags] + sorted_tags = sorted(annotated_tags, key=lambda x: x[1]) + + # Return the first (earliest) tag + return sorted_tags[0] + + except Exception as e: + self.logger.exception("get_first_tag_and_date_failed", repo=repo_slug) + return None + + def get_gitmodules(self, repo_slug: str = None, ref: dict = None) -> str: + """ + Get the .gitmodules file for the repo from the GitHub API. + + :param repo_slug: str, the repository slug + :param ref: dict, the Git reference object (the commit hash). See https://docs.github.com/en/rest/git/refs + for expected format. + :return: str, the .gitmodules file from the repo + """ + if not repo_slug: + repo_slug = self.repo_slug + + if not ref: + ref = self.get_ref() tree_sha = ref["object"]["sha"] tree = self.get_tree(tree_sha=tree_sha) - gitmodules = None for item in tree["tree"]: if item["path"] == ".gitmodules": file_sha = item["sha"] blob = self.get_blob(repo_slug=repo_slug, file_sha=file_sha) return base64.b64decode(blob["content"]) - def get_libraries_json(self, repo_slug: str): + def get_libraries_json(self, repo_slug: str, tag: str = "master"): """ Retrieve library metadata from 'meta/libraries.json' Each Boost library will have a `meta` directory with a `libraries.json` file. Example: https://github.com/boostorg/align/blob/5ad7df63cd792fbdb801d600b93cad1a432f0151/meta/libraries.json """ - url = f"https://raw.githubusercontent.com/{self.owner}/{repo_slug}/develop/meta/libraries.json" + url = f"https://raw.githubusercontent.com/{self.owner}/{repo_slug}/{tag}/meta/libraries.json" try: response = requests.get(url) - return response.json() - except Exception: + response.raise_for_status() + # This usually happens because the library does not have a `meta/libraries.json` file + # in the requested tag. More likely to happen with older versions of libraries. + except requests.exceptions.HTTPError: self.logger.exception( "get_library_metadata_failed", repo=repo_slug, url=url ) return None + else: + return response.json() def get_ref(self, repo_slug: str = None, ref: str = None) -> dict: """ @@ -133,7 +198,7 @@ class GithubAPIClient: repo_slug = self.repo_slug if not ref: ref = self.ref - return self.api.git.get_ref(owner=self.owner, repo=repo_slug, ref=ref) + return self.api.git.get_ref(owner=self.owner, repo=repo_slug, ref=f"tags/{ref}") def get_repo(self, repo_slug: str = None) -> dict: """ @@ -147,7 +212,7 @@ class GithubAPIClient: return self.api.repos.get(owner=self.owner, repo=repo_slug) def get_repo_issues( - owner: str, repo_slug: str, state: str = "all", issues_only: bool = True + self, owner: str, repo_slug: str, state: str = "all", issues_only: bool = True ): """ Get all issues for a repo. @@ -205,6 +270,41 @@ class GithubAPIClient: return results + def get_tag_by_name(self, tag_name: str, repo_slug: str = None) -> dict: + """Get a tag by name from the GitHub API.""" + if not repo_slug: + repo_slug = self.repo_slug + try: + return self.api.repos.get_release_by_tag( + owner=self.owner, repo=repo_slug, tag=tag_name + ) + except Exception as e: + logger.info("tag_not_found", tag_name=tag_name, repo_slug=repo_slug) + return + + def get_tags(self, repo_slug: str = None) -> dict: + """Get all the tags from the GitHub API.""" + if not repo_slug: + repo_slug = self.repo_slug + + per_page = 50 + page = 1 + tags = [] + + while True: + new_tags = self.api.repos.list_tags( + owner=self.owner, repo=repo_slug, per_page=per_page, page=page + ) + tags.extend(new_tags) + + # Check if we reached the last page + if len(new_tags) < per_page: + break + + page += 1 + + return tags + def get_tree(self, repo_slug: str = None, tree_sha: str = None) -> dict: """ Get the tree from the GitHub API. @@ -225,9 +325,21 @@ class GithubAPIClient: class GithubDataParser: + def parse_commit(self, commit_data: dict) -> dict: + """Parse the commit data from Github and return a dict of the data we want.""" + published_at = commit_data["committer"]["date"] + description = commit_data.get("message", "") + github_url = commit_data["html_url"] + release_date = datetime.strptime(published_at, "%Y-%m-%dT%H:%M:%SZ").date() + return { + "release_date": release_date, + "description": description, + "github_url": github_url, + "data": obj2dict(commit_data), + } + def parse_gitmodules(self, gitmodules: str) -> dict: - """ - Parse the .gitmodules file. + """Parse the .gitmodules file. Expects the multiline contents of https://github.com/boostorg/boost/.gitmodules to be passed in :param gitmodules: str, the .gitmodules file @@ -255,9 +367,7 @@ class GithubDataParser: return modules def parse_libraries_json(self, libraries_json: dict) -> dict: - """ - Parse the individual library metadata from 'meta/libraries.json' - """ + """Parse the individual library metadata from 'meta/libraries.json'.""" return { "name": libraries_json["name"], "key": libraries_json["key"], @@ -268,8 +378,21 @@ class GithubDataParser: "cxxstd": libraries_json.get("cxxstd"), } + def parse_tag(self, tag_data: dict) -> dict: + """Parse the tag data from Github and return a dict of the data we want.""" + published_at = tag_data.get("published_at", "") + description = tag_data.get("body", "") + github_url = tag_data.get("html_url", "") + release_date = datetime.strptime(published_at, "%Y-%m-%dT%H:%M:%SZ").date() + return { + "release_date": release_date, + "description": description, + "github_url": github_url, + "data": obj2dict(tag_data), + } + def extract_contributor_data(self, contributor: str) -> dict: - """Takes an author/maintainer string and returns a dict with their data""" + """Takes an author/maintainer string and returns a dict with their data.""" data = {} email = self.extract_email(contributor) @@ -342,11 +465,13 @@ class LibraryUpdater: and their `libraries.json` file metadata. """ - def __init__(self, owner="boostorg"): - self.client = GithubAPIClient(owner=owner) + def __init__(self, client=None): + if client: + self.client = client + else: + self.client = GithubAPIClient() self.api = self.client.initialize_api() self.parser = GithubDataParser() - self.owner = owner self.logger = structlog.get_logger() # Modules we need to skip as they are not really Boost Libraries @@ -414,8 +539,8 @@ class LibraryUpdater: "update_all_libraries_metadata", library_count=len(library_data) ) - for library_data in library_data: - library = self.update_library(library_data) + for lib in library_data: + self.update_library(lib) def update_library(self, library_data: dict) -> Library: """Update an individual library""" @@ -446,6 +571,9 @@ class LibraryUpdater: # Do authors second because maintainers are more likely to have emails to match self.update_authors(obj, authors=library_data["authors"]) + if created or not obj.first_github_tag_date: + self.update_first_github_tag_date(obj) + return obj except Exception: @@ -509,6 +637,17 @@ class LibraryUpdater: return obj + def update_first_github_tag_date(self, obj): + """ + Update the date of the first tag for a library + """ + first_tag = self.client.get_first_tag(repo_slug=obj.github_repo) + if first_tag: + _, first_github_tag_date = first_tag + obj.first_github_tag_date = parse_date(first_github_tag_date) + obj.save() + self.logger.info("lib_first_release_updated", obj_id=obj.id) + def update_maintainers(self, obj, maintainers=None): """ Receives a list of strings from the libraries.json of a Boost library, and @@ -545,7 +684,7 @@ class LibraryUpdater: self.logger.info("updating_repo_issues") issues_data = self.client.get_repo_issues( - self.owner, obj.github_repo, state="all", issues_only=True + self.client.owner, obj.github_repo, state="all", issues_only=True ) for issue_dict in issues_data: diff --git a/libraries/management/commands/import_first_release_dates.py b/libraries/management/commands/import_first_release_dates.py new file mode 100644 index 00000000..9ebdf654 --- /dev/null +++ b/libraries/management/commands/import_first_release_dates.py @@ -0,0 +1,58 @@ +import djclick as click + +from libraries.github import GithubAPIClient, LibraryUpdater +from libraries.models import Library + + +@click.command() +@click.option( + "--library", is_flag=False, help="Name of library (example: Accumulators)" +) +@click.option( + "--limit", + is_flag=False, + type=int, + help="Number of libraries to update (example: 10)", +) +@click.option("--token", is_flag=False, help="Github API token") +def command(token, library, limit): + """This command fetches and updates the date of the first GitHub tag for a given library, or for + all libraries if no library is specified. + + It uses the GitHub API to fetch the tag data, and you may pass your own token. + + The command updates the `first_github_tag_date` field of each library. + After the update, it prints out the library name and the date of the first tag. + If no tags were found for a library, it prints a warning message. + + Arguments: + token: str, optional -- The GitHub API token for authentication. Default value is set in the + client class. + library: str, optional -- The name of the library for which to fetch the tag data. If not provided, + the command fetches data for all libraries. Case-insensitive. + limit: int, optional -- The number of libraries to update. If not provided, the command updates all + libraries. If this is not passed, this command can take a long time to run. + """ + client = GithubAPIClient(token=token) + updater = LibraryUpdater(client=client) + + if library: + libraries = Library.objects.filter(name__iexact=library) + else: + libraries = Library.objects.filter(first_github_tag_date__isnull=True) + + if limit: + libraries = libraries[: int(limit)] + + for library in libraries: + updater.update_first_github_tag_date(library) + + for library in libraries: + library.refresh_from_db() + if library.first_github_tag_date: + click.secho( + f"{library.name} - First tag: {library.first_github_tag_date.strftime('%m-%Y')}", + fg="green", + ) + else: + click.secho(f"{library.name} - No tags found", fg="red") diff --git a/libraries/management/commands/import_library_versions.py b/libraries/management/commands/import_library_versions.py new file mode 100644 index 00000000..7373b9ad --- /dev/null +++ b/libraries/management/commands/import_library_versions.py @@ -0,0 +1,143 @@ +import djclick as click + +from fastcore.net import HTTP422UnprocessableEntityError +from libraries.github import GithubAPIClient, GithubDataParser, LibraryUpdater +from libraries.models import Library, LibraryVersion +from versions.models import Version + + +@click.command() +@click.option("--token", is_flag=False, help="Github API token") +@click.option("--release", is_flag=False, help="Boost version number (example: 1.81.0)") +def command(release, token): + """Cycles through all Versions in the database, and for each version gets the + corresponding tag's .gitmodules. + + The command then goes to the same tag of the repo for each library in the + .gitmodules file and uses the information to create LibraryVersion instances, and + add maintainers to LibraryVersions. + + Args: + token (str): Github API token, if you need to use something other than the setting. + release (str): Boost version number (example: 1.81.0). If a partial version number is + provided, the command process all versions that contain the partial version number + (example: "--version="1.7" would process 1.7.0, 1.7.1, 1.7.2, etc.) + """ + client = GithubAPIClient(token=token) + parser = GithubDataParser() + updater = LibraryUpdater(client=client) + + skipped = [] + + if release is None: + versions = Version.objects.active() + else: + versions = Version.objects.filter(name__icontains=release) + + for version in versions: + click.echo(f"Processing version {version.name}...") + + # Get the .gitmodules for this version using the version name, which is also the git tag + ref = client.get_ref(ref=version.name) + try: + raw_gitmodules = client.get_gitmodules(ref=ref) + except HTTP422UnprocessableEntityError as e: + # Only happens for one version; uncertain why. + click.secho(f"Could not get gitmodules for {version.name}.", fg="red") + skipped.append({"version": version.name, "reason": str(e)}) + continue + + gitmodules = parser.parse_gitmodules(raw_gitmodules.decode("utf-8")) + + for gitmodule in gitmodules: + library_name = gitmodule["module"] + click.echo(f"Processing module {library_name}...") + + if library_name in updater.skip_modules: + click.echo(f"Skipping module {library_name}.") + continue + + libraries_json = client.get_libraries_json(repo_slug=library_name) + + # If the libraries.json file exists, we can use it to get the library info + if libraries_json: + libraries = ( + libraries_json + if isinstance(libraries_json, list) + else [libraries_json] + ) + parsed_libraries = [ + parser.parse_libraries_json(lib) for lib in libraries + ] + for lib_data in parsed_libraries: + library_version = handle_library_version( + version, lib_data["name"], lib_data["maintainers"], updater + ) + if not library_version: + click.secho( + f"Could not save library version {lib_data['name']}.", + fg="red", + ) + skipped.append( + { + "version": version.name, + "library": lib_data["name"], + "reason": "Could not save library version", + } + ) + else: + # This can happen with older tags; the libraries.json file didn't always exist, so + # when it isn't present, we search for the library by the module name and try to save + # the LibraryVersion that way. + click.echo( + f"Could not get libraries.json for {library_name}; will try to save by gitmodule name." + ) + library_version = handle_library_version( + version, library_name, [], updater + ) + if not library_version: + click.secho( + f"Could not save library version {lib_data['name']}.", fg="red" + ) + skipped.append( + { + "version": version.name, + "library": lib_data["name"], + "reason": "Could not save library version", + } + ) + + skipped_messages = [ + f"Skipped {skipped_obj['library']} in {skipped_obj['version']}: {skipped_obj['reason']}" + if "library" in skipped_obj + else f"Skipped {skipped_obj['version']}: {skipped_obj['reason']}" + for skipped_obj in skipped + ] + + for message in skipped_messages: + click.secho(message, fg="red") + + +def handle_library_version(version, library_name, maintainers, updater): + """Handles the creation and updating of a LibraryVersion instance.""" + try: + library = Library.objects.get(name=library_name) + except Library.DoesNotExist: + click.secho( + f"Could not find library by gitmodule name; skipping {library_name}", + fg="red", + ) + return + + library_version, created = LibraryVersion.objects.get_or_create( + version=version, library=library + ) + click.secho( + f"Saved library version {library_version}. Created? {created}", fg="green" + ) + + if created: + updater.update_maintainers(library_version, maintainers=maintainers) + click.secho(f"Updated maintainers for {library_version}.", fg="green") + + return library_version diff --git a/libraries/migrations/0008_alter_libraryversion_library_and_more.py b/libraries/migrations/0008_alter_libraryversion_library_and_more.py new file mode 100644 index 00000000..c4d51c6e --- /dev/null +++ b/libraries/migrations/0008_alter_libraryversion_library_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2 on 2023-05-12 22:05 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("versions", "0007_version_data_version_github_url"), + ("libraries", "0007_auto_20230323_1912"), + ] + + operations = [ + migrations.AlterField( + model_name="libraryversion", + name="library", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="library_version", + to="libraries.library", + ), + ), + migrations.AlterField( + model_name="libraryversion", + name="version", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="library_version", + to="versions.version", + ), + ), + ] diff --git a/libraries/migrations/0009_library_first_github_tag_date.py b/libraries/migrations/0009_library_first_github_tag_date.py new file mode 100644 index 00000000..095bdb2e --- /dev/null +++ b/libraries/migrations/0009_library_first_github_tag_date.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.1 on 2023-05-18 21:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("libraries", "0008_alter_libraryversion_library_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="library", + name="first_github_tag_date", + field=models.DateField( + blank=True, + help_text="The date of the first release, based on the date of the commit of the first GitHub tag.", + null=True, + ), + ), + ] diff --git a/libraries/models.py b/libraries/models.py index 23473d61..80207ade 100644 --- a/libraries/models.py +++ b/libraries/models.py @@ -1,6 +1,7 @@ from urllib.parse import urlparse from django.db import models +from django.utils.functional import cached_property from django.utils.text import slugify @@ -77,6 +78,11 @@ class Library(models.Model): cpp_standard_minimum = models.CharField(max_length=50, blank=True, null=True) active_development = models.BooleanField(default=True, db_index=True) + first_github_tag_date = models.DateField( + blank=True, + null=True, + help_text="The date of the first release, based on the date of the commit of the first GitHub tag.", + ) last_github_update = models.DateTimeField(blank=True, null=True, db_index=True) categories = models.ManyToManyField(Category, related_name="libraries") @@ -94,11 +100,28 @@ class Library(models.Model): return self.name def save(self, *args, **kwargs): + """Override the save method to confirm the slug is set (or set it)""" if not self.slug: self.slug = slugify(self.name) return super().save(*args, **kwargs) + def get_cpp_standard_minimum_display(self): + """Returns the display name for the C++ standard, or the value if not found. + + Source of values is + https://docs.cppalliance.org/user-guide/prev/library_metadata.html""" + display_names = { + "98": "C++98", + "03": "C++03", + "11": "C++11", + "14": "C++14", + "17": "C++17", + "20": "C++20", + } + return display_names.get(self.cpp_standard_minimum, self.cpp_standard_minimum) + def github_properties(self): + """Returns the owner and repo name for the library""" parts = urlparse(self.github_url) path = parts.path.split("/") @@ -110,26 +133,40 @@ class Library(models.Model): "repo": repo, } - @property + @cached_property def github_owner(self): + """Returns the name of the GitHub owner for the library""" return self.github_properties()["owner"] - @property + @cached_property def github_repo(self): + """Returns the name of the GitHub repository for the library""" return self.github_properties()["repo"] + @cached_property + def github_issues_url(self): + """ + Returns the URL to the GitHub issues page for the library + + Does not check if the URL is valid. + """ + if not self.github_owner or not self.github_repo: + raise ValueError("Invalid GitHub owner or repository") + + return f"https://github.com/{self.github_owner}/{self.github_repo}/issues" + class LibraryVersion(models.Model): version = models.ForeignKey( "versions.Version", related_name="library_version", - on_delete=models.SET_NULL, + on_delete=models.CASCADE, null=True, ) library = models.ForeignKey( "libraries.Library", related_name="library_version", - on_delete=models.SET_NULL, + on_delete=models.CASCADE, null=True, ) maintainers = models.ManyToManyField("users.User", related_name="maintainers") diff --git a/libraries/tasks.py b/libraries/tasks.py new file mode 100644 index 00000000..771d1952 --- /dev/null +++ b/libraries/tasks.py @@ -0,0 +1,34 @@ +import structlog + +from celery.schedules import crontab + +from config.celery import app +from libraries.github import LibraryUpdater + + +logger = structlog.getLogger(__name__) + + +@app.on_after_configure.connect +def setup_periodic_tasks(sender, **kwargs): + # Executes every 5th of the month at 7:30 a.m. + sender.add_periodic_task( + crontab(hour=7, minute=30, day_of_month=5), + update_libraries.s(), + ) + + +@app.task +def update_libraries(): + """Update local libraries from GitHub Boost libraries. + + Use the LibraryUpdater, which retrieves the active boost libraries from the + Boost GitHub repo, to update the models with the latest information on that + library (repo) along with its issues, pull requests, and related objects + from GitHub. + + """ + updater = LibraryUpdater() + updater.update_libraries() + + logger.info("libraries_tasks_update_libraries_finished") diff --git a/libraries/tests/test_forms.py b/libraries/tests/test_forms.py index 96b2759e..800e7c90 100644 --- a/libraries/tests/test_forms.py +++ b/libraries/tests/test_forms.py @@ -1,6 +1,21 @@ -from ..forms import LibraryForm +from ..forms import LibraryForm, VersionSelectionForm def test_library_form_success(tp, library, category): form = LibraryForm(data={"categories": [category]}) assert form.is_valid() is True + + +def test_version_selection_form(library_version): + # Test with a valid version + valid_version = library_version.version + form = VersionSelectionForm(data={"version": valid_version.pk}) + assert form.is_valid() + + # Test with an invalid version + form = VersionSelectionForm(data={"version": 9999}) + assert form.is_valid() is False + + # Test with no version selected + form = VersionSelectionForm(data={"version": None}) + assert form.is_valid() is False diff --git a/libraries/tests/test_github.py b/libraries/tests/test_github.py index 6ec912ac..5eca693f 100644 --- a/libraries/tests/test_github.py +++ b/libraries/tests/test_github.py @@ -1,3 +1,4 @@ +import datetime from unittest.mock import MagicMock, patch import pytest @@ -5,11 +6,7 @@ import responses from ghapi.all import GhApi from model_bakery import baker -from libraries.github import ( - GithubAPIClient, - GithubDataParser, - LibraryUpdater, -) +from libraries.github import GithubAPIClient, GithubDataParser, LibraryUpdater from libraries.models import Category, Issue, Library, PullRequest """GithubAPIClient Tests""" @@ -23,7 +20,7 @@ def github_api_client(): @pytest.fixture(scope="function") def mock_api() -> GhApi: """Fixture that mocks the GitHub API.""" - with patch("libraries.github_new.GhApi") as mock_api_class: + with patch("libraries.github.GhApi") as mock_api_class: yield mock_api_class.return_value @@ -104,11 +101,41 @@ def test_get_blob(github_api_client): # ) +@pytest.mark.skip(reason="Mocking the API is not working") +def test_get_first_tag(github_api_client, mock_api): + """Test the get_first_tag method of GithubAPIClient.""" + + # Mock tags from the GitHub API + mock_tags = [ + {"name": "tag2", "commit": {"sha": "2"}}, + {"name": "tag1", "commit": {"sha": "1"}}, + ] + + # Mock the commit data from the GitHub API + mock_commits = [ + {"sha": "2", "committer": {"date": "2023-05-12T00:00:00Z"}}, + {"sha": "1", "committer": {"date": "2023-05-11T00:00:00Z"}}, + ] + + # Setup the mock API to return the mock tags and commits + github_api_client.api.repos.list_tags.side_effect = MagicMock( + return_value=mock_tags + ) + github_api_client.api.git.get_commit.side_effect = MagicMock( + return_value=mock_commits + ) + repo_slug = "sample_repo" + tag = github_api_client.get_first_tag(repo_slug=repo_slug) + + # Assert that the earliest tag was returned + assert tag == (mock_tags[1], "2000-01-01T00:00:00Z") + + @responses.activate def test_get_libraries_json(github_api_client): """Test the get_libraries_json method of GitHubAPIClient.""" repo_slug = "sample_repo" - url = f"https://raw.githubusercontent.com/{github_api_client.owner}/{repo_slug}/develop/meta/libraries.json" + url = f"https://raw.githubusercontent.com/{github_api_client.owner}/{repo_slug}/master/meta/libraries.json" sample_json = {"key": "math", "name": "Math"} responses.add( responses.GET, @@ -190,6 +217,38 @@ def test_parse_libraries_json(): parser.parse_libraries_json(sample_libraries_json) +def test_parse_commit(): + commit_data = { + "committer": {"date": "2023-05-10T00:00:00Z"}, + "message": "This is a sample description for a commit", + "html_url": "http://example.com/commit/12345", + } + expected = { + "release_date": datetime.date(2023, 5, 10), + "description": commit_data["message"], + "github_url": "http://example.com/commit/12345", + "data": commit_data, + } + result = GithubDataParser().parse_commit(commit_data) + assert result == expected + + +def test_parse_tag(): + tag_data = { + "published_at": "2023-05-10T00:00:00Z", + "body": "This is a sample description for a tag", + "html_url": "http://example.com/commit/12345", + } + expected = { + "release_date": datetime.date(2023, 5, 10), + "description": "This is a sample description for a tag", + "github_url": "http://example.com/commit/12345", + "data": tag_data, + } + result = GithubDataParser().parse_tag(tag_data) + assert result == expected + + def test_extract_names(): sample = "Tester Testerson " expected = ["Tester", "Testerson"] diff --git a/libraries/tests/test_models.py b/libraries/tests/test_models.py index b554921b..179ae31c 100644 --- a/libraries/tests/test_models.py +++ b/libraries/tests/test_models.py @@ -1,6 +1,16 @@ from model_bakery import baker +def test_get_cpp_standard_minimum_display(library): + library.cpp_standard_minimum = "11" + library.save() + assert library.get_cpp_standard_minimum_display() == "C++11" + + library.cpp_standard_minimum = "42" + library.save() + assert library.get_cpp_standard_minimum_display() == "42" + + def test_github_properties(library): properties = library.github_properties() assert properties["owner"] == "boostorg" @@ -15,6 +25,12 @@ def test_github_repo(library): assert library.github_repo == "multi_array" +def test_get_issues_link(library): + result = library.github_issues_url + expected = f"https://github.com/{library.github_owner}/{library.github_repo}/issues" + assert expected == result + + def test_category_creation(category): assert category.name is not None @@ -46,9 +62,7 @@ def test_library_version_multiple_versions(library, library_version): library_version__version=library_version.version ).exists() other_version = baker.make("versions.Version", name="New Version") - new_library_version = baker.make( - "libraries.LibraryVersion", library=library, version=other_version - ) + baker.make("libraries.LibraryVersion", library=library, version=other_version) assert library.versions.count() == 2 assert library.versions.filter( library_version__version=library_version.version diff --git a/libraries/views.py b/libraries/views.py index 63e4a4ba..fbf62764 100644 --- a/libraries/views.py +++ b/libraries/views.py @@ -1,12 +1,12 @@ import structlog from django.http import Http404 -from django.shortcuts import redirect +from django.shortcuts import get_object_or_404, redirect from django.views.generic import DetailView, ListView from django.views.generic.edit import FormMixin from versions.models import Version -from .forms import LibraryForm +from .forms import LibraryForm, VersionSelectionForm from .models import Category, Issue, Library, LibraryVersion, PullRequest logger = structlog.get_logger() @@ -52,9 +52,10 @@ class LibraryList(CategoryMixin, FormMixin, ListView): return super().get(request) -class LibraryDetail(CategoryMixin, DetailView): +class LibraryDetail(CategoryMixin, FormMixin, DetailView): """Display a single Library in insolation""" + form_class = VersionSelectionForm model = Library template_name = "libraries/detail.html" @@ -63,13 +64,22 @@ class LibraryDetail(CategoryMixin, DetailView): context = super().get_context_data(**kwargs) context["closed_prs_count"] = self.get_closed_prs_count(self.object) context["open_issues_count"] = self.get_open_issues_count(self.object) - context["version"] = Version.objects.most_recent() + context["version"] = self.get_version() context["maintainers"] = self.get_maintainers(context["version"]) + context["versions"] = ( + Version.objects.active() + .filter(library_version__library=self.object) + .distinct() + .order_by("-release_date") + ) return context def get_object(self): + """Get the current library object from the slug in the URL. + If present, use the version_slug to get the right LibraryVersion of the library. + Otherwise, default to the most recent version.""" slug = self.kwargs.get("slug") - version = Version.objects.most_recent() + version = self.get_version() if not LibraryVersion.objects.filter( version=version, library__slug=slug @@ -83,16 +93,42 @@ class LibraryDetail(CategoryMixin, DetailView): return obj def get_closed_prs_count(self, obj): + """Get the number of closed pull requests for the current library.""" return PullRequest.objects.filter(library=obj, is_open=True).count() def get_maintainers(self, version): + """Get the maintainers for the current LibraryVersion.""" obj = self.get_object() library_version = LibraryVersion.objects.get(library=obj, version=version) return library_version.maintainers.all() def get_open_issues_count(self, obj): + """Get the number of open issues for the current library.""" return Issue.objects.filter(library=obj, is_open=True).count() + def get_version(self): + """Get the version of Boost for the library we're currently looking at.""" + version_slug = self.kwargs.get("version_slug") + if version_slug: + return get_object_or_404(Version, slug=version_slug) + else: + return Version.objects.most_recent() + + def post(self, request, *args, **kwargs): + """User has submitted a form and will be redirected to the right LibraryVersion.""" + self.object = self.get_object() + form = self.get_form() + if form.is_valid(): + version = form.cleaned_data["version"] + return redirect( + "library-detail-by-version", + version_slug=version.slug, + slug=self.object.slug, + ) + else: + logger.info("library_list_invalid_version") + return super().get(request) + class LibraryByCategory(CategoryMixin, FormMixin, ListView): """List all of our libraries for the current version of Boost in a certain category""" @@ -174,56 +210,6 @@ class LibraryListByVersion(CategoryMixin, FormMixin, ListView): return super().get(request) -class LibraryDetailByVersion(CategoryMixin, DetailView): - """Display a single Library for a specific Boost version""" - - model = Library - template_name = "libraries/detail.html" - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - object = self.get_object() - context["closed_prs_count"] = self.get_closed_prs_count(object) - context["open_issues_count"] = self.get_open_issues_count(object) - context["version_slug"] = self.kwargs.get("version_slug") - context["version"] = self.get_version(self.kwargs.get("version_slug")) - context["version_name"] = context["version"].name - context["maintainers"] = self.get_maintainers(context["version"]) - return context - - def get_object(self): - version_slug = self.kwargs.get("version_slug") - slug = self.kwargs.get("slug") - - if not LibraryVersion.objects.filter( - version__slug=version_slug, library__slug=slug - ).exists(): - raise Http404("No library found matching the query") - try: - obj = self.get_queryset().get(slug=slug) - except self.model.DoesNotExist: - raise Http404("No library found matching the query") - return obj - - def get_closed_prs_count(self, obj): - return PullRequest.objects.filter(library=obj, is_open=True).count() - - def get_maintainers(self, version): - obj = self.get_object() - library_version = LibraryVersion.objects.get(library=obj, version=version) - return library_version.maintainers.all() - - def get_open_issues_count(self, obj): - return Issue.objects.filter(library=obj, is_open=True).count() - - def get_version(self, version_slug): - try: - return Version.objects.get(slug=version_slug) - except Version.DoesNotExist: - logger.info("libraries_by_version_detail_view_version_not_found") - raise Http404("No object found matching the query") - - class LibraryListByVersionByCategory(CategoryMixin, FormMixin, ListView): """List all of our libraries in a certain category for a certain Boost version""" diff --git a/news/__init__.py b/news/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/news/admin.py b/news/admin.py new file mode 100644 index 00000000..741baba4 --- /dev/null +++ b/news/admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin + +from .models import Entry + + +class EntryAdmin(admin.ModelAdmin): + list_display = ["title", "author", "created_at", "approved_at", "publish_at"] + readonly_fields = ["modified_at"] + prepopulated_fields = {"slug": ["title"]} + + +admin.site.register(Entry, EntryAdmin) diff --git a/news/apps.py b/news/apps.py new file mode 100644 index 00000000..e50c4540 --- /dev/null +++ b/news/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NewsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "news" diff --git a/news/forms.py b/news/forms.py new file mode 100644 index 00000000..724dfa92 --- /dev/null +++ b/news/forms.py @@ -0,0 +1,8 @@ +from django import forms +from .models import Entry + + +class EntryForm(forms.ModelForm): + class Meta: + model = Entry + fields = ["title", "description"] diff --git a/news/migrations/0001_initial.py b/news/migrations/0001_initial.py new file mode 100644 index 00000000..7c842f46 --- /dev/null +++ b/news/migrations/0001_initial.py @@ -0,0 +1,142 @@ +# Generated by Django 4.2 on 2023-05-12 18:34 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Entry", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("slug", models.SlugField()), + ("title", models.CharField(max_length=255)), + ("description", models.TextField(blank=True, default="")), + ("external_url", models.URLField(blank=True, default="")), + ("image", models.ImageField(blank=True, null=True, upload_to="news")), + ("created_at", models.DateTimeField(default=django.utils.timezone.now)), + ("publish_at", models.DateTimeField(default=django.utils.timezone.now)), + ( + "author", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name_plural": "Entries", + }, + ), + migrations.CreateModel( + name="BlogPost", + fields=[ + ( + "entry_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="news.entry", + ), + ), + ("body", models.TextField()), + ("abstract", models.CharField(max_length=256)), + ], + bases=("news.entry",), + ), + migrations.CreateModel( + name="Link", + fields=[ + ( + "entry_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="news.entry", + ), + ), + ], + bases=("news.entry",), + ), + migrations.CreateModel( + name="Poll", + fields=[ + ( + "entry_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="news.entry", + ), + ), + ], + bases=("news.entry",), + ), + migrations.CreateModel( + name="Video", + fields=[ + ( + "entry_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="news.entry", + ), + ), + ], + bases=("news.entry",), + ), + migrations.CreateModel( + name="PollChoice", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("wording", models.CharField(max_length=200)), + ("order", models.PositiveIntegerField()), + ("votes", models.ManyToManyField(to=settings.AUTH_USER_MODEL)), + ( + "poll", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="news.poll" + ), + ), + ], + ), + ] diff --git a/news/migrations/0002_entry_approved_at_entry_moderator_entry_modified_at.py b/news/migrations/0002_entry_approved_at_entry_moderator_entry_modified_at.py new file mode 100644 index 00000000..11bc8c73 --- /dev/null +++ b/news/migrations/0002_entry_approved_at_entry_moderator_entry_modified_at.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2 on 2023-05-17 16:41 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("news", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="entry", + name="approved_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="entry", + name="moderator", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="moderated_entries_set", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="entry", + name="modified_at", + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/news/migrations/__init__.py b/news/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/news/models.py b/news/models.py new file mode 100644 index 00000000..b10adf58 --- /dev/null +++ b/news/models.py @@ -0,0 +1,132 @@ +from django.contrib.auth import get_user_model +from django.db import models +from django.urls import reverse +from django.utils.text import slugify +from django.utils.timezone import now + + +User = get_user_model() + + +class EntryManager(models.Manager): + def get_queryset(self): + return ( + super() + .get_queryset() + .annotate( + approved=models.Q(moderator__isnull=False, approved_at__lte=now()) + ) + .annotate(published=models.Q(publish_at__lte=now(), approved=True)) + ) + + +class Entry(models.Model): + """A news entry. + + Please note that this is a concrete class with its own DB table. Children + of this class have their own table with their own attributes, plus a 1-1 + relationship with their parent. + + """ + + class AlreadyApprovedError(Exception): + """The entry cannot be approved again.""" + + slug = models.SlugField() + title = models.CharField(max_length=255) + description = models.TextField(blank=True, default="") + author = models.ForeignKey(User, on_delete=models.CASCADE) + moderator = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="moderated_entries_set", + ) + external_url = models.URLField(blank=True, default="") + image = models.ImageField(upload_to="news", null=True, blank=True) + created_at = models.DateTimeField(default=now) + approved_at = models.DateTimeField(null=True, blank=True) + modified_at = models.DateTimeField(auto_now=True) + publish_at = models.DateTimeField(default=now) + + objects = EntryManager() + + class Meta: + verbose_name_plural = "Entries" + + def __str__(self): + return f"{self.title} by {self.author}" + + @property + def is_approved(self): + return ( + self.moderator is not None + and self.approved_at is not None + and self.approved_at <= now() + ) + + @property + def is_published(self): + return self.is_approved and self.publish_at <= now() + + def approve(self, user): + """Mark this entry as approved by the given `user`.""" + if self.is_approved: + raise self.AlreadyApprovedError() + self.moderator = user + self.approved_at = now() + self.save(update_fields=["moderator", "approved_at", "modified_at"]) + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.title) + return super().save(*args, **kwargs) + + def get_absolute_url(self): + return reverse("news-detail", args=[self.slug]) + + def can_view(self, user): + return ( + self.is_published + or user == self.author + or (user is not None and user.has_perm("news.view_entry")) + ) + + def can_approve(self, user): + return user is not None and user.has_perm("news.change_entry") + + def can_edit(self, user): + return (not self.is_approved and user == self.author) or ( + user is not None and user.has_perm("news.change_entry") + ) + + def can_delete(self, user): + return user is not None and user.has_perm("news.delete_entry") + + +class BlogPost(Entry): + body = models.TextField() + abstract = models.CharField(max_length=256) + # Possible extra fields: RSS feed? banner? keywords? + + +class Link(Entry): + pass + + +class Video(Entry): + pass + # Possible extra fields: length? quality? + + +class Poll(Entry): + pass + # Possible extra fields: voting expiration date? + + +class PollChoice(models.Model): + poll = models.ForeignKey(Poll, on_delete=models.CASCADE) + wording = models.CharField(max_length=200) + order = models.PositiveIntegerField() + votes = models.ManyToManyField(User) diff --git a/news/tests/__init__.py b/news/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/news/tests/fixtures.py b/news/tests/fixtures.py new file mode 100644 index 00000000..9bef47dc --- /dev/null +++ b/news/tests/fixtures.py @@ -0,0 +1,55 @@ +import datetime + +import pytest +from django.contrib.auth.models import Permission +from django.utils.timezone import now +from model_bakery import baker + + +@pytest.fixture +def make_entry(db): + def _make_it(approved=True, published=True, **kwargs): + past = now() - datetime.timedelta(hours=1) + future = now() + datetime.timedelta(days=1) + if approved: + approved_at = past + moderator = baker.make("users.User") + else: + approved_at = None + moderator = None + if published: + publish_at = past + else: + publish_at = future + kwargs.setdefault("approved_at", approved_at) + kwargs.setdefault("moderator", moderator) + kwargs.setdefault("publish_at", publish_at) + entry = baker.make("Entry", **kwargs) + entry.author.set_password("password") + entry.author.save() + return entry + + return _make_it + + +@pytest.fixture +def moderator_user(db): + # we could use `tp.make_user` but we need this fix to be released + # https://github.com/revsys/django-test-plus/issues/199 + user = baker.make("users.User") + user.user_permissions.add( + *Permission.objects.filter( + content_type__app_label="news", content_type__model="entry" + ) + ) + user.set_password("password") + user.save() + return user + + +@pytest.fixture +def regular_user(db): + user = baker.make("users.User") + user.set_password("password") + user.save() + return user diff --git a/news/tests/test_forms.py b/news/tests/test_forms.py new file mode 100644 index 00000000..20a6f955 --- /dev/null +++ b/news/tests/test_forms.py @@ -0,0 +1,60 @@ +import datetime + +from django.utils.timezone import now +from model_bakery import baker + +from ..forms import EntryForm +from ..models import Entry + + +def test_form_fields(): + form = EntryForm() + assert sorted(form.fields.keys()) == ["description", "title"] + + +def test_form_model_creates_entry(make_entry): + title = "The Title" + description = "Some description" + user = baker.make("users.User") + assert Entry.objects.filter(title=title).count() == 0 + + before = now() + form = EntryForm(instance=None, data={"title": title, "description": description}) + assert form.is_valid() + form.instance.author = user + result = form.save() + after = now() + + assert isinstance(result, Entry) + assert result.pk is not None + assert before <= result.created_at <= after + assert before <= result.modified_at <= after + assert before <= result.publish_at <= after + assert result.approved_at is None + assert result.title == title + assert result.description == description + assert result.author == user + assert result.moderator is None + assert Entry.objects.get(pk=result.pk) == result + + +def test_form_model_modifies_entry(make_entry): + # Guard against runs that are too fast and this modified_at would not change + past = now() - datetime.timedelta(minutes=1) + news = make_entry(title="Old title", modified_at=past) + form = EntryForm(instance=news, data={"title": "New title"}) + assert form.is_valid() + + result = form.save() + + # Modified fields + assert result.title == "New title" + assert result.modified_at > past + # Unchanged fields + assert result.pk == news.pk + assert result.created_at == news.created_at + assert result.approved_at == news.approved_at + assert result.publish_at == news.publish_at + assert result.description == news.description + assert result.author == news.author + assert result.moderator == news.moderator diff --git a/news/tests/test_models.py b/news/tests/test_models.py new file mode 100644 index 00000000..2776eb0c --- /dev/null +++ b/news/tests/test_models.py @@ -0,0 +1,270 @@ +import datetime + +import pytest +from django.contrib.auth.models import Permission +from django.utils.timezone import now +from model_bakery import baker + +from ..models import Entry, Poll + + +def test_entry_str(): + entry = baker.make("Entry") + assert str(entry) == f"{entry.title} by {entry.author}" + + +def test_entry_generate_slug(): + author = baker.make("users.User") + entry = Entry.objects.create(title="😀 Foo Bar Baz!@! +", author=author) + assert entry.slug == "foo-bar-baz" + + +def test_entry_slug_not_overwriten(): + author = baker.make("users.User") + entry = Entry.objects.create(title="Foo!", author=author, slug="different") + assert entry.slug == "different" + + +def test_entry_approved(make_entry): + entry = make_entry(moderator=baker.make("users.User"), approved_at=now()) + assert entry.is_approved is True + + +def test_entry_not_approved(make_entry): + entry = make_entry(moderator=None, approved_at=now()) + assert entry.is_approved is False + + entry = make_entry(moderator=baker.make("users.User"), approved_at=None) + assert entry.is_approved is False + + future = now() + datetime.timedelta(minutes=1) + entry = make_entry(moderator=baker.make("users.User"), approved_at=future) + assert entry.is_approved is False + + +def test_entry_published(make_entry): + entry = make_entry(approved=True, publish_at=now()) + assert entry.is_published is True + + +def test_entry_not_published(make_entry): + entry = make_entry(approved=False, publish_at=now()) + assert entry.is_published is False + + future = now() + datetime.timedelta(minutes=1) + entry = make_entry(approved=True, publish_at=future) + assert entry.is_published is False + + +def test_entry_absolute_url(): + entry = baker.make("Entry", slug="the-slug") + assert entry.get_absolute_url() == "/news/the-slug/" + + +def test_approve_entry(make_entry): + future = now() + datetime.timedelta(hours=1) + entry = make_entry(approved=False, publish_at=future) + assert not entry.is_approved + assert not entry.is_published + + user = baker.make("users.User") + before = now() + entry.approve(user) + after = now() + + entry.refresh_from_db() + assert entry.moderator == user + # Avoid mocking `now()`, yet still ensure that the approval timestamp + # ocurred between `before` and `after` + assert entry.approved_at <= after + assert entry.approved_at >= before + assert entry.is_approved + assert not entry.is_published + + +def test_approve_already_approved_entry(make_entry): + entry = make_entry(approved=True) + assert entry.is_approved + + with pytest.raises(Entry.AlreadyApprovedError): + entry.approve(baker.make("users.User")) + + +def test_entry_permissions_author(make_entry): + entry = make_entry(approved=False) + author = entry.author + assert entry.can_view(author) is True + assert entry.can_edit(author) is True + assert entry.can_delete(author) is False + assert entry.can_approve(author) is False + + entry.approve(baker.make("users.User")) + assert entry.can_view(author) is True + assert entry.can_edit(author) is False + assert entry.can_delete(author) is False + assert entry.can_approve(author) is False + + +def test_not_approved_entry_permissions_other_users(make_entry): + entry = make_entry(approved=False) + assert entry.can_view(None) is False + assert entry.can_edit(None) is False + assert entry.can_delete(None) is False + assert entry.can_approve(None) is False + + regular_user = baker.make("users.User") + assert entry.can_view(regular_user) is False + assert entry.can_edit(regular_user) is False + assert entry.can_delete(regular_user) is False + assert entry.can_approve(regular_user) is False + + superuser = baker.make("users.User", is_superuser=True) + assert entry.can_view(superuser) is True + assert entry.can_edit(superuser) is True + assert entry.can_delete(superuser) is True + assert entry.can_approve(superuser) is True + + user_with_add_perm = baker.make("users.User") + user_with_add_perm.user_permissions.add( + Permission.objects.get(codename="add_entry") + ) + assert entry.can_view(user_with_add_perm) is False + assert entry.can_edit(user_with_add_perm) is False + assert entry.can_delete(user_with_add_perm) is False + assert entry.can_approve(user_with_add_perm) is False + + user_with_change_perm = baker.make("users.User") + user_with_change_perm.user_permissions.add( + Permission.objects.get(codename="change_entry") + ) + assert entry.can_view(user_with_change_perm) is False + assert entry.can_edit(user_with_change_perm) is True + assert entry.can_delete(user_with_change_perm) is False + assert entry.can_approve(user_with_change_perm) is True + + user_with_delete_perm = baker.make("users.User") + user_with_delete_perm.user_permissions.add( + Permission.objects.get(codename="delete_entry") + ) + assert entry.can_view(user_with_delete_perm) is False + assert entry.can_edit(user_with_delete_perm) is False + assert entry.can_delete(user_with_delete_perm) is True + assert entry.can_approve(user_with_delete_perm) is False + + user_with_view_perm = baker.make("users.User") + user_with_view_perm.user_permissions.add( + Permission.objects.get(codename="view_entry") + ) + assert entry.can_view(user_with_view_perm) is True + assert entry.can_edit(user_with_view_perm) is False + assert entry.can_delete(user_with_view_perm) is False + assert entry.can_approve(user_with_view_perm) is False + + +def test_approved_entry_permissions_other_users(make_entry): + entry = make_entry(approved=True) + assert entry.can_view(None) is True + assert entry.can_edit(None) is False + assert entry.can_delete(None) is False + assert entry.can_approve(None) is False + + regular_user = baker.make("users.User") + assert entry.can_view(regular_user) is True + assert entry.can_edit(regular_user) is False + assert entry.can_delete(regular_user) is False + assert entry.can_approve(regular_user) is False + + superuser = baker.make("users.User", is_superuser=True) + assert entry.can_view(superuser) is True + assert entry.can_edit(superuser) is True + assert entry.can_delete(superuser) is True + assert entry.can_approve(superuser) is True + + user_with_add_perm = baker.make("users.User") + user_with_add_perm.user_permissions.add( + Permission.objects.get(codename="add_entry") + ) + assert entry.can_view(user_with_add_perm) is True + assert entry.can_edit(user_with_add_perm) is False + assert entry.can_delete(user_with_add_perm) is False + assert entry.can_approve(user_with_add_perm) is False + + user_with_change_perm = baker.make("users.User") + user_with_change_perm.user_permissions.add( + Permission.objects.get(codename="change_entry") + ) + assert entry.can_view(user_with_change_perm) is True + assert entry.can_edit(user_with_change_perm) is True + assert entry.can_delete(user_with_change_perm) is False + assert entry.can_approve(user_with_change_perm) is True + + user_with_delete_perm = baker.make("users.User") + user_with_delete_perm.user_permissions.add( + Permission.objects.get(codename="delete_entry") + ) + assert entry.can_view(user_with_delete_perm) is True + assert entry.can_edit(user_with_delete_perm) is False + assert entry.can_delete(user_with_delete_perm) is True + assert entry.can_approve(user_with_delete_perm) is False + + user_with_view_perm = baker.make("users.User") + user_with_view_perm.user_permissions.add( + Permission.objects.get(codename="view_entry") + ) + assert entry.can_view(user_with_view_perm) is True + assert entry.can_edit(user_with_view_perm) is False + assert entry.can_delete(user_with_view_perm) is False + assert entry.can_approve(user_with_view_perm) is False + + +def test_entry_manager_custom_queryset(make_entry): + entry_published = make_entry(approved=True, published=True) + entry_approved = make_entry(approved=True, published=False) + entry_not_approved = make_entry(approved=False) + entry_not_published = make_entry(approved=False, published=False) + + assert list(Entry.objects.filter(approved=True).order_by("id")) == [ + entry_published, + entry_approved, + ] + assert list(Entry.objects.filter(approved=False).order_by("id")) == [ + entry_not_approved, + entry_not_published, + ] + assert list(Entry.objects.filter(published=True).order_by("id")) == [ + entry_published + ] + assert list(Entry.objects.filter(published=False).order_by("id")) == [ + entry_approved, + entry_not_approved, + entry_not_published, + ] + + +def test_blogpost(): + blogpost = baker.make("BlogPost") + assert isinstance(blogpost, Entry) + assert Entry.objects.get(id=blogpost.id).blogpost == blogpost + + +def test_link(): + link = baker.make("Link") + assert isinstance(link, Entry) + assert Entry.objects.get(id=link.id).link == link + + +def test_video(): + video = baker.make("Video") + assert isinstance(video, Entry) + assert Entry.objects.get(id=video.id).video == video + + +def test_poll(): + poll = baker.make("Poll") + assert isinstance(poll, Entry) + assert Entry.objects.get(id=poll.id).poll == poll + + +def test_poll_choice(): + choice = baker.make("PollChoice") + assert isinstance(choice.poll, Poll) diff --git a/news/tests/test_views.py b/news/tests/test_views.py new file mode 100644 index 00000000..3b891c6c --- /dev/null +++ b/news/tests/test_views.py @@ -0,0 +1,244 @@ +import datetime + +from django.utils.timezone import now +from model_bakery import baker + +from ..forms import EntryForm +from ..models import Entry + + +def test_entry_list(tp, make_entry, regular_user, authenticated=False): + """List published news for non authenticated users.""" + not_approved_news = make_entry(approved=False, title="needs moderation") + yesterday_news = make_entry( + approved=True, title="old news", publish_at=now() - datetime.timedelta(days=1) + ) + today_news = make_entry( + approved=True, title="current news", publish_at=now().today() + ) + tomorrow_news = make_entry( + approved=True, + title="future news", + publish_at=now() + datetime.timedelta(days=1), + ) + + if authenticated: + tp.login(regular_user) + + response = tp.get("news") + + tp.response_200(response) + expected = [today_news, yesterday_news] + assert list(response.context.get("entry_list", [])) == expected + + content = str(response.content) + for n in expected: + assert n.get_absolute_url() in content + assert n.title in content + + assert not_approved_news.get_absolute_url() not in content + assert not_approved_news.title not in content + assert tomorrow_news.get_absolute_url() not in content + assert tomorrow_news.title not in content + + # If user is not authenticated, the Create News link should not be shown + assert (tp.reverse("news-create") in content) == authenticated + + +def test_entry_list_authenticated(tp, make_entry, regular_user): + test_entry_list(tp, make_entry, regular_user, authenticated=True) + + +def test_news_detail(tp, make_entry): + """Browse details for a given news entry.""" + a_past_date = now() - datetime.timedelta(hours=10) + news = make_entry(approved=True, publish_at=a_past_date) + url = tp.reverse("news-detail", news.slug) + + response = tp.get(url) + tp.response_200(response) + + content = str(response.content) + assert news.title in content + assert news.description in content + assert tp.reverse("news-approve", news.slug) not in content + + # no next nor prev links + assert "newer entries" not in content.lower() + assert "older entries" not in content.lower() + + # create an older news + older_date = a_past_date - datetime.timedelta(hours=1) + older = make_entry(approved=True, publish_at=older_date) + + response = tp.get(url) + tp.response_200(response) + + content = str(response.content) + assert "newer entries" not in content.lower() + assert "older entries" in content.lower() + assert older.get_absolute_url() in content + + # create a newer news, but still older than now so it's shown + newer_date = a_past_date + datetime.timedelta(hours=1) + assert newer_date < now() + newer = make_entry(approved=True, publish_at=newer_date) + + response = tp.get(url) + tp.response_200(response) + + content = str(response.content) + assert "newer entries" in content.lower() + assert "older entries" in content.lower() + assert newer.get_absolute_url() in content + + +def test_news_detail_404(tp): + """No news is good news.""" + url = tp.reverse("news-detail", "not-there") + response = tp.get(url) + tp.response_404(response) + + +def test_news_detail_404_if_not_published(tp, make_entry, regular_user): + """Details for a news entry are available if published or authored.""" + news = make_entry(published=False) + response = tp.get(news.get_absolute_url()) + tp.response_404(response) + + # even if logged in, a regular user can not access the unpublished news + with tp.login(regular_user): + response = tp.get(news.get_absolute_url()) + tp.response_404(response) + + # but the entry author can access it even if unpublished + with tp.login(news.author): + response = tp.get(news.get_absolute_url()) + tp.response_200(response) + + +def test_news_detail_actions_author(tp, make_entry): + """News entry is updatable by authors (if not approved).""" + news = make_entry(approved=False) # not approved entry + with tp.login(news.author): + response = tp.get(news.get_absolute_url()) + tp.response_200(response) + + content = str(response.content) + assert tp.reverse("news-approve", news.slug) not in content + + news.approve(baker.make("users.User")) + with tp.login(news.author): + response = tp.get(news.get_absolute_url()) + tp.response_200(response) + + content = str(response.content) + assert tp.reverse("news-approve", news.slug) not in content + + +def test_news_detail_actions_moderator(tp, make_entry, moderator_user): + """Moderators can update, delete and approve a news entry.""" + news = make_entry(approved=False) # approved entry + with tp.login(moderator_user): + response = tp.get(news.get_absolute_url()) + tp.response_200(response) + + content = str(response.content) + assert tp.reverse("news-approve", news.slug) in content + + news.approve(baker.make("users.User")) + with tp.login(moderator_user): + response = tp.get(news.get_absolute_url()) + tp.response_200(response) + + content = str(response.content) + assert tp.reverse("news-approve", news.slug) not in content + + +def test_news_create_get(tp, regular_user): + url_name = "news-create" + # assertLoginRequired expects a non resolved URL, that is an URL name + # see https://github.com/revsys/django-test-plus/issues/202 + tp.assertLoginRequired(url_name) + + with tp.login(regular_user): + # assertGoodView expects a resolved URL + # see https://github.com/revsys/django-test-plus/issues/202 + url = tp.reverse(url_name) + response = tp.assertGoodView(url, test_query_count=3, verbose=True) + + form = tp.get_context("form") + assert isinstance(form, EntryForm) + for field in form: + tp.assertResponseContains(str(field), response) + + +def test_news_create_post(tp, regular_user): + url = tp.reverse("news-create") + data = { + "title": "Lorem Ipsum", + "description": "Lorem ipsum dolor sit amet, consectetur adipiscing.", + } + before = now() + with tp.login(regular_user): + response = tp.post(url, data=data, follow=True) + after = now() + + entries = Entry.objects.filter(title=data["title"]) + assert len(entries) == 1 + entry = entries.get() + assert entry.slug == "lorem-ipsum" + assert entry.description == data["description"] + assert entry.author == regular_user + assert not entry.is_approved + assert not entry.is_published + # Avoid mocking `now()`, yet still ensure that the timestamps are + # between `before` and `after` + assert before <= entry.created_at <= after + assert before <= entry.modified_at <= after + + tp.assertRedirects(response, entry.get_absolute_url()) + + +def test_news_approve_get_method_not_allowed( + tp, make_entry, regular_user, moderator_user +): + entry = make_entry(approved=False) + + # login is required + url_params = ("news-approve", entry.slug) + tp.assertLoginRequired(*url_params) + + # regular users would get a 403 + with tp.login(regular_user): + response = tp.get(*url_params) + tp.response_403(response) + + # moderators users would get a 405 for GET + with tp.login(moderator_user): + response = tp.get(*url_params) + tp.response_405(response) + + +def test_news_approve_post(tp, make_entry, regular_user, moderator_user): + entry = make_entry(approved=False) + url_params = ("news-approve", entry.slug) + + # regular users would still get a 403 on POST + with tp.login(regular_user): + response = tp.post(*url_params) + tp.response_403(response) + + # moderators users can POST to the view to approve an entry + with tp.login(moderator_user): + before = now() + response = tp.post(*url_params) + after = now() + + tp.assertRedirects(response, entry.get_absolute_url()) + + entry.refresh_from_db() + assert entry.is_approved is True + assert entry.moderator == moderator_user + assert before <= entry.approved_at <= after + assert before <= entry.modified_at <= after diff --git a/news/views.py b/news/views.py new file mode 100644 index 00000000..73569598 --- /dev/null +++ b/news/views.py @@ -0,0 +1,83 @@ +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin +from django.http import Http404, HttpResponseRedirect +from django.utils.translation import gettext as _ +from django.views.generic import ( + CreateView, + DetailView, + ListView, + View, +) +from django.views.generic.detail import SingleObjectMixin + +from .models import Entry +from .forms import EntryForm + + +def get_published_or_none(sibling_getter): + """Helper method to get next/prev published sibling of a given entry.""" + try: + result = sibling_getter(published=True) + except Entry.DoesNotExist: + result = None + return result + + +class EntryListView(ListView): + model = Entry + template_name = "news/list.html" + ordering = ["-publish_at"] + paginate_by = 10 + + def get_queryset(self): + return super().get_queryset().filter(published=True) + + +class EntryDetailView(DetailView): + model = Entry + template_name = "news/detail.html" + + def get_object(self, *args, **kwargs): + # Published news are available to anyone, otherwise to authors only + result = super().get_object(*args, **kwargs) + if not result.can_view(self.request.user): + raise Http404() + return result + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["next"] = get_published_or_none(self.object.get_next_by_publish_at) + context["prev"] = get_published_or_none(self.object.get_previous_by_publish_at) + context["user_can_approve"] = self.object.can_approve(self.request.user) + return context + + +class EntryCreateView(LoginRequiredMixin, CreateView): + model = Entry + form_class = EntryForm + template_name = "news/form.html" + + def form_valid(self, form): + form.instance.author = self.request.user + return super().form_valid(form) + + +class EntryApproveView( + LoginRequiredMixin, UserPassesTestMixin, SingleObjectMixin, View +): + model = Entry + http_method_names = ["post"] + + def test_func(self): + entry = self.get_object() + return entry.can_approve(self.request.user) + + def post(self, request, *args, **kwargs): + entry = self.get_object() + try: + entry.approve(user=self.request.user) + except Entry.AlreadyApprovedError: + messages.error(request, _("The entry was already approved.")) + else: + messages.success(request, _("The entry was successfully approved.")) + return HttpResponseRedirect(entry.get_absolute_url()) diff --git a/package.json b/package.json index a91f64a2..0dc254e4 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { - "name": "boost.org", + "name": "boost.revsys.dev", "version": "1.0.0", "main": "index.js", - "repository": "git@github.com:revsys/boost.org.git", + "repository": "git@github.com:cppalliance/temp-site.git", "author": "Greg Newman ", "license": "MIT", "scripts": { diff --git a/requirements.in b/requirements.in index f1b9264f..7924c3c5 100755 --- a/requirements.in +++ b/requirements.in @@ -28,7 +28,7 @@ python-json-logger structlog # Celery -celery==5.2.2 +celery==5.2.7 redis==4.5.4 # Testing diff --git a/requirements.txt b/requirements.txt index eba381bf..536288e8 100755 --- a/requirements.txt +++ b/requirements.txt @@ -10,23 +10,21 @@ appdirs==1.4.4 # via fs asgiref==3.6.0 # via django -asttokens==2.0.5 +asttokens==2.2.1 # via stack-data async-timeout==4.0.2 # via redis -attrs==21.1.0 - # via pytest backcall==0.2.0 # via ipython billiard==3.6.4.0 # via celery black==22.3 # via -r ./requirements.in -boto3==1.17.68 +boto3==1.26.135 # via # -r ./requirements.in # django-bakery -botocore==1.20.68 +botocore==1.29.135 # via # boto3 # s3transfer @@ -36,15 +34,15 @@ bump2version==1.0.1 # via bumpversion bumpversion==0.6.0 # via -r ./requirements.in -celery==5.2.2 +celery==5.2.7 # via -r ./requirements.in -certifi==2022.6.15 +certifi==2023.5.7 # via # minio # requests cffi==1.15.1 # via cryptography -charset-normalizer==2.1.0 +charset-normalizer==3.1.0 # via requests click==8.1.3 # via @@ -55,15 +53,15 @@ click==8.1.3 # click-repl # django-click # pip-tools -click-didyoumean==0.0.3 +click-didyoumean==0.3.0 # via celery click-plugins==1.1.1 # via celery click-repl==0.2.0 # via celery -coverage==5.5 +coverage[toml]==7.2.5 # via pytest-cov -cryptography==39.0.1 +cryptography==40.0.2 # via # -r ./requirements.in # pyjwt @@ -71,45 +69,47 @@ decorator==5.1.1 # via ipython defusedxml==0.7.1 # via python3-openid -dj-database-url==0.5.0 +dj-database-url==2.0.0 # via environs -dj-email-url==1.0.2 +dj-email-url==1.0.6 # via environs -django==4.2 +django==4.2.1 # via # -r ./requirements.in + # dj-database-url # django-allauth # django-db-geventpool # django-extensions # django-haystack # django-health-check + # django-js-asset # django-machina # django-redis # django-rest-auth # django-storages # djangorestframework # model-bakery -django-admin-env-notice==0.4 +django-admin-env-notice==1.0 # via -r ./requirements.in django-allauth==0.53.1 # via -r ./requirements.in -django-bakery==0.12.7 +django-bakery==0.13.2 # via -r ./requirements.in -django-cache-url==3.2.3 +django-cache-url==3.4.4 # via environs django-click==2.3.0 # via -r ./requirements.in -django-db-geventpool==4.0.0 +django-db-geventpool==4.0.1 # via -r ./requirements.in -django-extensions==3.1.3 +django-extensions==3.2.1 # via -r ./requirements.in django-haystack==3.2.1 # via # -r ./requirements.in # django-machina -django-health-check==3.16.4 +django-health-check==3.17.0 # via -r ./requirements.in -django-js-asset==1.2.2 +django-js-asset==2.0.0 # via django-mptt django-machina==1.2.0 # via -r ./requirements.in @@ -117,17 +117,17 @@ django-mptt==0.14.0 # via # -r ./requirements.in # django-machina -django-redis==5.0.0 +django-redis==5.2.0 # via -r ./requirements.in django-rest-auth==0.9.5 # via -r ./requirements.in django-storages==1.13.2 # via -r ./requirements.in -django-test-plus==1.4.0 +django-test-plus==2.2.1 # via -r ./requirements.in django-tracer==0.9.3 # via -r ./requirements.in -django-widget-tweaks==1.4.9 +django-widget-tweaks==1.4.12 # via # -r ./requirements.in # django-machina @@ -135,19 +135,19 @@ djangorestframework==3.14.0 # via # -r ./requirements.in # django-rest-auth -environs[django]==9.3.2 +environs[django]==9.5.0 # via -r ./requirements.in -executing==0.8.3 +executing==1.2.0 # via stack-data -faker==9.8.2 +faker==18.9.0 # via -r ./requirements.in -fastcore==1.5.5 +fastcore==1.5.29 # via ghapi -fs==2.4.13 +fs==2.4.16 # via django-bakery gevent==22.10.2 # via -r ./requirements.in -ghapi==0.1.23 +ghapi==1.0.3 # via -r ./requirements.in greenlet==2.0.1 # via @@ -155,41 +155,42 @@ greenlet==2.0.1 # gevent gunicorn==20.1.0 # via -r ./requirements.in -idna==3.3 +idna==3.4 # via requests -iniconfig==1.1.1 +iniconfig==2.0.0 # via pytest -ipython==8.4.0 +ipython==8.13.2 # via -r ./requirements.in -jedi==0.18.1 +jedi==0.18.2 # via ipython -jmespath==0.10.0 +jmespath==1.0.1 # via # boto3 # botocore kombu==5.2.4 # via celery -markdown2==2.4.1 +markdown2==2.4.8 # via django-machina -marshmallow==3.11.1 +marshmallow==3.19.0 # via environs -matplotlib-inline==0.1.3 +matplotlib-inline==0.1.6 # via ipython minio==7.1.14 # via -r ./requirements.in -mistletoe==0.8.2 +mistletoe==1.0.1 # via -r ./requirements.in -model-bakery==1.11 +model-bakery==1.11.0 # via -r ./requirements.in -mypy-extensions==0.4.3 +mypy-extensions==1.0.0 # via black oauthlib==3.2.2 # via requests-oauthlib -packaging==23.0 +packaging==23.1 # via # build # fastcore # ghapi + # marshmallow # pytest parso==0.8.3 # via jedi @@ -205,11 +206,11 @@ pillow==9.4.0 # django-machina pip-tools==6.13.0 # via -r ./requirements.in -platformdirs==3.2.0 +platformdirs==3.5.1 # via black -pluggy==0.13.1 +pluggy==1.0.0 # via pytest -prompt-toolkit==3.0.18 +prompt-toolkit==3.0.38 # via # click-repl # ipython @@ -223,40 +224,41 @@ pure-eval==0.2.2 # via stack-data pycparser==2.21 # via cffi -pygments==2.12.0 +pygments==2.15.1 # via ipython -pyjwt[crypto]==2.6.0 +pyjwt[crypto]==2.7.0 # via django-allauth pyproject-hooks==1.0.0 # via build -pytest==7.2.2 +pytest==7.3.1 # via # -r ./requirements.in # pytest-cov # pytest-django -pytest-cov==2.11.1 +pytest-cov==4.0.0 # via -r ./requirements.in -pytest-django==4.2.0 +pytest-django==4.5.2 # via -r ./requirements.in -python-dateutil==2.8.1 +python-dateutil==2.8.2 # via # botocore # faker -python-dotenv==0.17.1 +python-dotenv==1.0.0 # via environs python-frontmatter==1.0.0 # via -r ./requirements.in -python-json-logger==2.0.1 +python-json-logger==2.0.7 # via -r ./requirements.in python3-openid==3.2.0 # via django-allauth -pytz==2021.1 +pytz==2023.3 # via # celery # djangorestframework - # fs pyyaml==6.0 - # via python-frontmatter + # via + # python-frontmatter + # responses redis==4.5.4 # via # -r ./requirements.in @@ -269,9 +271,9 @@ requests==2.28.2 # responses requests-oauthlib==1.3.1 # via django-allauth -responses==0.22.0 +responses==0.23.1 # via -r ./requirements.in -s3transfer==0.4.2 +s3transfer==0.6.1 # via boto3 six==1.16.0 # via @@ -280,23 +282,21 @@ six==1.16.0 # django-rest-auth # fs # python-dateutil -sqlparse==0.4.1 +sqlparse==0.4.4 # via django -stack-data==0.2.0 +stack-data==0.6.2 # via ipython -structlog==21.1.0 +structlog==23.1.0 # via -r ./requirements.in -text-unidecode==1.3 - # via faker -toml==0.10.2 - # via responses -traitlets==5.2.1.post0 +traitlets==5.9.0 # via # ipython # matplotlib-inline -types-toml==0.10.8.1 +types-pyyaml==6.0.12.9 # via responses -urllib3==1.26.4 +typing-extensions==4.5.0 + # via dj-database-url +urllib3==1.26.15 # via # botocore # minio @@ -306,17 +306,17 @@ vine==5.0.0 # via # celery # kombu -wcwidth==0.2.5 +wcwidth==0.2.6 # via prompt-toolkit -wheel==0.38.1 +wheel==0.40.0 # via # -r ./requirements.in # pip-tools -whitenoise==5.2.0 +whitenoise==6.4.0 # via -r ./requirements.in -zope-event==4.5.0 +zope-event==4.6 # via gevent -zope-interface==5.4.0 +zope-interface==6.0 # via gevent # The following packages are considered to be unsafe in a requirements file: diff --git a/stage_static_config.json b/stage_static_config.json index bb9500c2..4c3863ea 100644 --- a/stage_static_config.json +++ b/stage_static_config.json @@ -1,4 +1,8 @@ [ + { + "site_path": "/doc/", + "s3_path": "/site-docs/develop/" + }, { "site_path": "/doc/user-guide/", "s3_path": "/site-docs/develop/user-guide/" diff --git a/static/css/styles.css b/static/css/styles.css index f320ff44..a6916310 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -1,5 +1,5 @@ /* -! tailwindcss v3.2.1 | MIT License | https://tailwindcss.com +! tailwindcss v3.3.2 | MIT License | https://tailwindcss.com */ /* @@ -30,6 +30,8 @@ 2. Prevent adjustments of font size after orientation changes in iOS. 3. Use a more readable tab size. 4. Use the user's configured `sans` font-family by default. +5. Use the user's configured `sans` font-feature-settings by default. +6. Use the user's configured `sans` font-variation-settings by default. */ html { @@ -44,6 +46,10 @@ html { /* 3 */ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; /* 4 */ + font-feature-settings: normal; + /* 5 */ + font-variation-settings: normal; + /* 6 */ } /* @@ -591,7 +597,7 @@ body { color: rgb(49 74 87 / var(--tw-text-opacity)); } -.dark body { +:is(.dark body) { --tw-text-opacity: 1; color: rgb(255 255 255 / var(--tw-text-opacity)); } @@ -611,7 +617,7 @@ h2 { color: rgb(49 74 87 / var(--tw-text-opacity)); } -.dark h2 { +:is(.dark h2) { --tw-text-opacity: 1; color: rgb(255 255 255 / var(--tw-text-opacity)); } @@ -664,7 +670,7 @@ textarea { background-color: rgb(255 255 255 / var(--tw-bg-opacity)); } -.dark textarea { +:is(.dark textarea) { --tw-bg-opacity: 1; background-color: rgb(23 42 52 / var(--tw-bg-opacity)); } @@ -680,7 +686,7 @@ input[type=email] { background-color: rgb(255 255 255 / var(--tw-bg-opacity)); } -.dark input[type=email] { +:is(.dark input[type=email]) { --tw-bg-opacity: 1; background-color: rgb(23 42 52 / var(--tw-bg-opacity)); } @@ -697,7 +703,7 @@ input[type=checkbox] { color: rgb(255 159 0 / var(--tw-text-opacity)); } -.dark input[type=checkbox] { +:is(.dark input[type=checkbox]) { --tw-bg-opacity: 1; background-color: rgb(23 42 52 / var(--tw-bg-opacity)); } @@ -730,7 +736,7 @@ input[type=file] { transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); } -.dark input[type=file] { +:is(.dark input[type=file]) { --tw-text-opacity: 1; color: rgb(255 255 255 / var(--tw-text-opacity)); } @@ -748,8 +754,8 @@ input[type=file] { color: rgb(49 74 87 / var(--tw-text-opacity)); } -.dark #signup_form input[type=email],.dark - #signup_form input[type=password] { +:is(.dark #signup_form input[type=email]),:is(.dark + #signup_form input[type=password]) { --tw-bg-opacity: 1; background-color: rgb(23 42 52 / var(--tw-bg-opacity)); --tw-text-opacity: 1; @@ -760,6 +766,39 @@ input[type=file] { display: none; } +/* Dark mode scrollbar */ + +.dark ::-webkit-scrollbar { + --tw-bg-opacity: 1; + background-color: rgb(75 85 99 / var(--tw-bg-opacity)); +} + +.dark ::-webkit-scrollbar-track { + --tw-bg-opacity: 1; + background-color: rgb(55 65 81 / var(--tw-bg-opacity)); +} + +.dark ::-webkit-scrollbar-thumb { + height: 1.25rem; + width: 1.25rem; + --tw-bg-opacity: 1; + background-color: rgb(75 85 99 / var(--tw-bg-opacity)); +} + +.dark ::-webkit-scrollbar-button:start:decrement { + --tw-bg-opacity: 1; + background-color: rgb(55 65 81 / var(--tw-bg-opacity)); + background-image: url('data:image/svg+xml;charset=UTF-8,'); + object-position: center; +} + +.dark ::-webkit-scrollbar-button:end:increment { + --tw-bg-opacity: 1; + background-color: rgb(55 65 81 / var(--tw-bg-opacity)); + background-image: url('data:image/svg+xml;charset=UTF-8,'); + object-position: center; +} + *, ::before, ::after { --tw-border-spacing-x: 0; --tw-border-spacing-y: 0; @@ -774,6 +813,9 @@ input[type=file] { --tw-pan-y: ; --tw-pinch-zoom: ; --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; --tw-ordinal: ; --tw-slashed-zero: ; --tw-numeric-figure: ; @@ -821,6 +863,9 @@ input[type=file] { --tw-pan-y: ; --tw-pinch-zoom: ; --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; --tw-ordinal: ; --tw-slashed-zero: ; --tw-numeric-figure: ; @@ -929,15 +974,7 @@ input[type=file] { } .inset-0 { - top: 0px; - right: 0px; - bottom: 0px; - left: 0px; -} - -.inset-y-0 { - top: 0px; - bottom: 0px; + inset: 0px; } .inset-x-0 { @@ -945,30 +982,43 @@ input[type=file] { right: 0px; } -.top-5 { - top: 1.25rem; -} - -.right-10 { - right: 2.5rem; -} - -.-top-2 { - top: -0.5rem; +.inset-y-0 { + top: 0px; + bottom: 0px; } .-right-2 { right: -0.5rem; } -.right-3 { - right: 0.75rem; +.-top-\[2px\] { + top: -2px; +} + +.bottom-0 { + bottom: 0px; +} + +.bottom-10 { + bottom: 2.5rem; +} + +.left-0 { + left: 0px; +} + +.left-4 { + left: 1rem; } .right-0 { right: 0px; } +.right-3 { + right: 0.75rem; +} + .top-0 { top: 0px; } @@ -977,34 +1027,18 @@ input[type=file] { top: 2.5rem; } -.left-0 { - left: 0px; -} - -.bottom-10 { - bottom: 2.5rem; -} - -.bottom-0 { - bottom: 0px; -} - .top-14 { top: 3.5rem; } -.top-3\.5 { - top: 0.875rem; -} - -.left-4 { - left: 1rem; -} - .top-3 { top: 0.75rem; } +.top-3\.5 { + top: 0.875rem; +} + .z-10 { z-index: 10; } @@ -1017,6 +1051,10 @@ input[type=file] { order: 1; } +.order-first { + order: -9999; +} + .float-right { float: right; } @@ -1025,67 +1063,12 @@ input[type=file] { float: left; } -.m-10 { - margin: 2.5rem; -} - .m-0 { margin: 0px; } -.my-8 { - margin-top: 2rem; - margin-bottom: 2rem; -} - -.mx-auto { - margin-left: auto; - margin-right: auto; -} - -.my-2 { - margin-top: 0.5rem; - margin-bottom: 0.5rem; -} - -.my-5 { - margin-top: 1.25rem; - margin-bottom: 1.25rem; -} - -.my-4 { - margin-top: 1rem; - margin-bottom: 1rem; -} - -.my-16 { - margin-top: 4rem; - margin-bottom: 4rem; -} - -.my-3 { - margin-top: 0.75rem; - margin-bottom: 0.75rem; -} - -.my-6 { - margin-top: 1.5rem; - margin-bottom: 1.5rem; -} - -.my-11 { - margin-top: 2.75rem; - margin-bottom: 2.75rem; -} - -.my-1 { - margin-top: 0.25rem; - margin-bottom: 0.25rem; -} - -.my-9 { - margin-top: 2.25rem; - margin-bottom: 2.25rem; +.m-10 { + margin: 2.5rem; } .mx-3 { @@ -1093,116 +1076,59 @@ input[type=file] { margin-right: 0.75rem; } -.mb-4 { - margin-bottom: 1rem; -} - -.ml-3 { - margin-left: 0.75rem; -} - -.mr-auto { +.mx-auto { + margin-left: auto; margin-right: auto; } -.mr-2 { - margin-right: 0.5rem; -} - -.ml-11 { - margin-left: 2.75rem; -} - -.mt-2 { - margin-top: 0.5rem; -} - -.mb-16 { - margin-bottom: 4rem; -} - -.mt-6 { - margin-top: 1.5rem; -} - -.mt-3 { - margin-top: 0.75rem; -} - -.mt-5 { - margin-top: 1.25rem; -} - -.mb-3 { - margin-bottom: 0.75rem; -} - -.mb-11 { - margin-bottom: 2.75rem; -} - -.ml-6 { - margin-left: 1.5rem; -} - -.mb-2 { - margin-bottom: 0.5rem; -} - -.mb-6 { - margin-bottom: 1.5rem; -} - -.mr-5 { - margin-right: 1.25rem; -} - -.mr-3 { - margin-right: 0.75rem; -} - -.mt-1 { +.my-1 { margin-top: 0.25rem; -} - -.mt-0 { - margin-top: 0px; -} - -.mt-4 { - margin-top: 1rem; -} - -.mb-5 { - margin-bottom: 1.25rem; -} - -.mb-0 { - margin-bottom: 0px; -} - -.mb-8 { - margin-bottom: 2rem; -} - -.mr-4 { - margin-right: 1rem; -} - -.mt-11 { - margin-top: 2.75rem; -} - -.mb-1 { margin-bottom: 0.25rem; } -.mt-16 { - margin-top: 4rem; +.my-11 { + margin-top: 2.75rem; + margin-bottom: 2.75rem; } -.mr-11 { - margin-right: 2.75rem; +.my-16 { + margin-top: 4rem; + margin-bottom: 4rem; +} + +.my-2 { + margin-top: 0.5rem; + margin-bottom: 0.5rem; +} + +.my-3 { + margin-top: 0.75rem; + margin-bottom: 0.75rem; +} + +.my-4 { + margin-top: 1rem; + margin-bottom: 1rem; +} + +.my-5 { + margin-top: 1.25rem; + margin-bottom: 1.25rem; +} + +.my-6 { + margin-top: 1.5rem; + margin-bottom: 1.5rem; +} + +.my-8 { + margin-top: 2rem; + margin-bottom: 2rem; +} + +.my-9 { + margin-top: 2.25rem; + margin-bottom: 2.25rem; } .-mb-1 { @@ -1213,22 +1139,130 @@ input[type=file] { margin-top: -0.25rem; } -.mr-1 { - margin-right: 0.25rem; +.mb-0 { + margin-bottom: 0px; +} + +.mb-1 { + margin-bottom: 0.25rem; +} + +.mb-11 { + margin-bottom: 2.75rem; +} + +.mb-16 { + margin-bottom: 4rem; +} + +.mb-2 { + margin-bottom: 0.5rem; +} + +.mb-3 { + margin-bottom: 0.75rem; +} + +.mb-4 { + margin-bottom: 1rem; +} + +.mb-5 { + margin-bottom: 1.25rem; +} + +.mb-6 { + margin-bottom: 1.5rem; +} + +.mb-8 { + margin-bottom: 2rem; +} + +.ml-11 { + margin-left: 2.75rem; } .ml-2 { margin-left: 0.5rem; } -.ml-5 { - margin-left: 1.25rem; +.ml-3 { + margin-left: 0.75rem; } .ml-4 { margin-left: 1rem; } +.ml-5 { + margin-left: 1.25rem; +} + +.ml-6 { + margin-left: 1.5rem; +} + +.mr-1 { + margin-right: 0.25rem; +} + +.mr-2 { + margin-right: 0.5rem; +} + +.mr-3 { + margin-right: 0.75rem; +} + +.mr-4 { + margin-right: 1rem; +} + +.mr-5 { + margin-right: 1.25rem; +} + +.mr-auto { + margin-right: auto; +} + +.mt-0 { + margin-top: 0px; +} + +.mt-1 { + margin-top: 0.25rem; +} + +.mt-11 { + margin-top: 2.75rem; +} + +.mt-16 { + margin-top: 4rem; +} + +.mt-2 { + margin-top: 0.5rem; +} + +.mt-3 { + margin-top: 0.75rem; +} + +.mt-4 { + margin-top: 1rem; +} + +.mt-5 { + margin-top: 1.25rem; +} + +.mt-6 { + margin-top: 1.5rem; +} + .block { display: block; } @@ -1265,36 +1299,12 @@ input[type=file] { display: none; } -.h-screen { - height: 100vh; +.h-12 { + height: 3rem; } -.h-96 { - height: 24rem; -} - -.h-8 { - height: 2rem; -} - -.h-\[40px\] { - height: 40px; -} - -.h-\[50px\] { - height: 50px; -} - -.h-6 { - height: 1.5rem; -} - -.h-full { - height: 100%; -} - -.h-\[30px\] { - height: 30px; +.h-32 { + height: 8rem; } .h-4 { @@ -1305,138 +1315,154 @@ input[type=file] { height: 1.25rem; } +.h-6 { + height: 1.5rem; +} + +.h-\[100px\] { + height: 100px; +} + .h-\[15px\] { height: 15px; } -.h-32 { - height: 8rem; +.h-\[30px\] { + height: 30px; } -.h-12 { - height: 3rem; +.h-\[32px\] { + height: 32px; } -.max-h-\[470px\] { - max-height: 470px; +.h-full { + height: 100%; +} + +.h-screen { + height: 100vh; } .max-h-96 { max-height: 24rem; } -.w-full { - width: 100%; -} - -.w-1\/2 { - width: 50%; -} - -.w-auto { - width: auto; -} - -.w-2\/3 { - width: 66.666667%; -} - -.w-1\/6 { - width: 16.666667%; -} - -.w-3\/4 { - width: 75%; -} - -.w-1\/4 { - width: 25%; -} - -.w-5\/6 { - width: 83.333333%; -} - -.w-1\/3 { - width: 33.333333%; -} - -.w-\[200px\] { - width: 200px; -} - -.w-32 { - width: 8rem; -} - -.w-\[30px\] { - width: 30px; -} - -.w-6 { - width: 1.5rem; -} - -.w-\[90\%\] { - width: 90%; -} - -.w-4\/6 { - width: 66.666667%; -} - -.w-\[47px\] { - width: 47px; -} - -.w-4 { - width: 1rem; -} - -.w-5 { - width: 1.25rem; -} - -.w-1\/5 { - width: 20%; -} - -.w-\[80px\] { - width: 80px; +.max-h-\[470px\] { + max-height: 470px; } .w-1 { width: 0.25rem; } -.w-4\/5 { - width: 80%; +.w-1\/2 { + width: 50%; } -.w-2\/5 { - width: 40%; +.w-1\/3 { + width: 33.333333%; +} + +.w-1\/4 { + width: 25%; +} + +.w-1\/5 { + width: 20%; +} + +.w-1\/6 { + width: 16.666667%; +} + +.w-2\/3 { + width: 66.666667%; +} + +.w-3\/4 { + width: 75%; +} + +.w-32 { + width: 8rem; +} + +.w-4 { + width: 1rem; +} + +.w-4\/6 { + width: 66.666667%; +} + +.w-44 { + width: 11rem; +} + +.w-5 { + width: 1.25rem; +} + +.w-5\/6 { + width: 83.333333%; +} + +.w-6 { + width: 1.5rem; +} + +.w-\[130px\] { + width: 130px; +} + +.w-\[200px\] { + width: 200px; +} + +.w-\[30px\] { + width: 30px; +} + +.w-\[80px\] { + width: 80px; +} + +.w-\[90\%\] { + width: 90%; +} + +.w-auto { + width: auto; +} + +.w-full { + width: 100%; } .min-w-0 { min-width: 0px; } -.max-w-md { - max-width: 28rem; -} - -.max-w-7xl { - max-width: 80rem; +.min-w-\[300px\] { + min-width: 300px; } .max-w-2xl { max-width: 42rem; } +.max-w-7xl { + max-width: 80rem; +} + .max-w-full { max-width: 100%; } +.max-w-md { + max-width: 28rem; +} + .flex-auto { flex: 1 1 auto; } @@ -1453,6 +1479,10 @@ input[type=file] { table-layout: auto; } +.border-collapse { + border-collapse: collapse; +} + .border-spacing-2 { --tw-border-spacing-x: 0.5rem; --tw-border-spacing-y: 0.5rem; @@ -1463,30 +1493,30 @@ input[type=file] { transform-origin: top right; } -.scale-95 { - --tw-scale-x: .95; - --tw-scale-y: .95; - transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); -} - .scale-100 { --tw-scale-x: 1; --tw-scale-y: 1; transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } +.scale-95 { + --tw-scale-x: .95; + --tw-scale-y: .95; + transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); +} + .transform { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } -.cursor-pointer { - cursor: pointer; -} - .cursor-default { cursor: default; } +.cursor-pointer { + cursor: pointer; +} + .select-none { -webkit-user-select: none; -moz-user-select: none; @@ -1501,14 +1531,14 @@ input[type=file] { grid-auto-flow: column; } -.grid-cols-2 { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - .grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); } +.grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + .grid-rows-5 { grid-template-rows: repeat(5, minmax(0, 1fr)); } @@ -1525,10 +1555,6 @@ input[type=file] { flex-wrap: wrap; } -.content-center { - align-content: center; -} - .content-between { align-content: space-between; } @@ -1557,44 +1583,30 @@ input[type=file] { gap: 1rem; } +.gap-6 { + gap: 1.5rem; +} + .gap-8 { gap: 2rem; } -.space-y-4 > :not([hidden]) ~ :not([hidden]) { - --tw-space-y-reverse: 0; - margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); - margin-bottom: calc(1rem * var(--tw-space-y-reverse)); -} - -.space-y-3 > :not([hidden]) ~ :not([hidden]) { - --tw-space-y-reverse: 0; - margin-top: calc(0.75rem * calc(1 - var(--tw-space-y-reverse))); - margin-bottom: calc(0.75rem * var(--tw-space-y-reverse)); -} - -.space-y-2 > :not([hidden]) ~ :not([hidden]) { - --tw-space-y-reverse: 0; - margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse))); - margin-bottom: calc(0.5rem * var(--tw-space-y-reverse)); -} - -.space-y-8 > :not([hidden]) ~ :not([hidden]) { - --tw-space-y-reverse: 0; - margin-top: calc(2rem * calc(1 - var(--tw-space-y-reverse))); - margin-bottom: calc(2rem * var(--tw-space-y-reverse)); -} - -.space-y-11 > :not([hidden]) ~ :not([hidden]) { - --tw-space-y-reverse: 0; - margin-top: calc(2.75rem * calc(1 - var(--tw-space-y-reverse))); - margin-bottom: calc(2.75rem * var(--tw-space-y-reverse)); -} - -.space-x-4 > :not([hidden]) ~ :not([hidden]) { +.space-x-0 > :not([hidden]) ~ :not([hidden]) { --tw-space-x-reverse: 0; - margin-right: calc(1rem * var(--tw-space-x-reverse)); - margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse))); + margin-right: calc(0px * var(--tw-space-x-reverse)); + margin-left: calc(0px * calc(1 - var(--tw-space-x-reverse))); +} + +.space-x-11 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(2.75rem * var(--tw-space-x-reverse)); + margin-left: calc(2.75rem * calc(1 - var(--tw-space-x-reverse))); +} + +.space-x-2 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(0.5rem * var(--tw-space-x-reverse)); + margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))); } .space-x-24 > :not([hidden]) ~ :not([hidden]) { @@ -1609,52 +1621,70 @@ input[type=file] { margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse))); } +.space-x-4 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(1rem * var(--tw-space-x-reverse)); + margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse))); +} + +.space-x-6 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(1.5rem * var(--tw-space-x-reverse)); + margin-left: calc(1.5rem * calc(1 - var(--tw-space-x-reverse))); +} + .space-x-8 > :not([hidden]) ~ :not([hidden]) { --tw-space-x-reverse: 0; margin-right: calc(2rem * var(--tw-space-x-reverse)); margin-left: calc(2rem * calc(1 - var(--tw-space-x-reverse))); } +.space-y-11 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(2.75rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(2.75rem * var(--tw-space-y-reverse)); +} + +.space-y-2 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(0.5rem * var(--tw-space-y-reverse)); +} + +.space-y-3 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(0.75rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(0.75rem * var(--tw-space-y-reverse)); +} + +.space-y-4 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(1rem * var(--tw-space-y-reverse)); +} + .space-y-5 > :not([hidden]) ~ :not([hidden]) { --tw-space-y-reverse: 0; margin-top: calc(1.25rem * calc(1 - var(--tw-space-y-reverse))); margin-bottom: calc(1.25rem * var(--tw-space-y-reverse)); } -.space-x-10 > :not([hidden]) ~ :not([hidden]) { - --tw-space-x-reverse: 0; - margin-right: calc(2.5rem * var(--tw-space-x-reverse)); - margin-left: calc(2.5rem * calc(1 - var(--tw-space-x-reverse))); -} - -.space-x-5 > :not([hidden]) ~ :not([hidden]) { - --tw-space-x-reverse: 0; - margin-right: calc(1.25rem * var(--tw-space-x-reverse)); - margin-left: calc(1.25rem * calc(1 - var(--tw-space-x-reverse))); -} - -.space-x-2 > :not([hidden]) ~ :not([hidden]) { - --tw-space-x-reverse: 0; - margin-right: calc(0.5rem * var(--tw-space-x-reverse)); - margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))); -} - .space-y-6 > :not([hidden]) ~ :not([hidden]) { --tw-space-y-reverse: 0; margin-top: calc(1.5rem * calc(1 - var(--tw-space-y-reverse))); margin-bottom: calc(1.5rem * var(--tw-space-y-reverse)); } -.space-x-11 > :not([hidden]) ~ :not([hidden]) { - --tw-space-x-reverse: 0; - margin-right: calc(2.75rem * var(--tw-space-x-reverse)); - margin-left: calc(2.75rem * calc(1 - var(--tw-space-x-reverse))); +.space-y-8 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(2rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(2rem * var(--tw-space-y-reverse)); } -.space-x-0 > :not([hidden]) ~ :not([hidden]) { - --tw-space-x-reverse: 0; - margin-right: calc(0px * var(--tw-space-x-reverse)); - margin-left: calc(0px * calc(1 - var(--tw-space-x-reverse))); +.divide-x > :not([hidden]) ~ :not([hidden]) { + --tw-divide-x-reverse: 0; + border-right-width: calc(1px * var(--tw-divide-x-reverse)); + border-left-width: calc(1px * calc(1 - var(--tw-divide-x-reverse))); } .divide-y > :not([hidden]) ~ :not([hidden]) { @@ -1668,11 +1698,6 @@ input[type=file] { border-color: rgb(229 231 235 / var(--tw-divide-opacity)); } -.divide-slate > :not([hidden]) ~ :not([hidden]) { - --tw-divide-opacity: 1; - border-color: rgb(49 74 87 / var(--tw-divide-opacity)); -} - .divide-gray-300 > :not([hidden]) ~ :not([hidden]) { --tw-divide-opacity: 1; border-color: rgb(209 213 219 / var(--tw-divide-opacity)); @@ -1683,6 +1708,11 @@ input[type=file] { border-color: rgb(107 114 128 / var(--tw-divide-opacity)); } +.divide-slate > :not([hidden]) ~ :not([hidden]) { + --tw-divide-opacity: 1; + border-color: rgb(49 74 87 / var(--tw-divide-opacity)); +} + .divide-opacity-20 > :not([hidden]) ~ :not([hidden]) { --tw-divide-opacity: 0.2; } @@ -1695,6 +1725,10 @@ input[type=file] { overflow-y: auto; } +.overflow-x-hidden { + overflow-x: hidden; +} + .overflow-y-hidden { overflow-y: hidden; } @@ -1709,14 +1743,18 @@ input[type=file] { overflow-wrap: break-word; } -.rounded-lg { - border-radius: 0.5rem; -} - .rounded { border-radius: 0.25rem; } +.rounded-full { + border-radius: 9999px; +} + +.rounded-lg { + border-radius: 0.5rem; +} + .rounded-md { border-radius: 0.375rem; } @@ -1725,10 +1763,6 @@ input[type=file] { border-radius: 0.125rem; } -.rounded-full { - border-radius: 9999px; -} - .rounded-xl { border-radius: 0.75rem; } @@ -1750,25 +1784,21 @@ input[type=file] { border-bottom-width: 1px; } -.border-t { - border-top-width: 1px; -} - .border-b-2 { border-bottom-width: 2px; } -.border-r { - border-right-width: 1px; -} - .border-b-4 { border-bottom-width: 4px; } -.border-gray-700 { +.border-t { + border-top-width: 1px; +} + +.border-gray-200 { --tw-border-opacity: 1; - border-color: rgb(55 65 81 / var(--tw-border-opacity)); + border-color: rgb(229 231 235 / var(--tw-border-opacity)); } .border-gray-300 { @@ -1776,51 +1806,33 @@ input[type=file] { border-color: rgb(209 213 219 / var(--tw-border-opacity)); } +.border-gray-700 { + --tw-border-opacity: 1; + border-color: rgb(55 65 81 / var(--tw-border-opacity)); +} + +.border-green\/60 { + border-color: rgb(90 213 153 / 0.6); +} + .border-orange { --tw-border-opacity: 1; border-color: rgb(255 159 0 / var(--tw-border-opacity)); } -.border-steel { - --tw-border-opacity: 1; - border-color: rgb(181 201 211 / var(--tw-border-opacity)); -} - .border-slate { --tw-border-opacity: 1; border-color: rgb(49 74 87 / var(--tw-border-opacity)); } -.border-green\/40 { - border-color: rgb(90 213 153 / 0.4); +.border-steel { + --tw-border-opacity: 1; + border-color: rgb(181 201 211 / var(--tw-border-opacity)); } -.bg-red-600 { +.bg-black { --tw-bg-opacity: 1; - background-color: rgb(220 38 38 / var(--tw-bg-opacity)); -} - -.bg-stone\/60 { - background-color: rgb(221 231 236 / 0.6); -} - -.bg-white { - --tw-bg-opacity: 1; - background-color: rgb(255 255 255 / var(--tw-bg-opacity)); -} - -.bg-orange { - --tw-bg-opacity: 1; - background-color: rgb(255 159 0 / var(--tw-bg-opacity)); -} - -.bg-gray-300 { - --tw-bg-opacity: 1; - background-color: rgb(209 213 219 / var(--tw-bg-opacity)); -} - -.bg-green\/10 { - background-color: rgb(90 213 153 / 0.1); + background-color: rgb(5 26 38 / var(--tw-bg-opacity)); } .bg-charcoal { @@ -1832,8 +1844,36 @@ input[type=file] { background-color: rgb(23 42 52 / 0.6); } -.bg-green\/20 { - background-color: rgb(90 213 153 / 0.2); +.bg-gray-100 { + --tw-bg-opacity: 1; + background-color: rgb(243 244 246 / var(--tw-bg-opacity)); +} + +.bg-gray-200 { + --tw-bg-opacity: 1; + background-color: rgb(229 231 235 / var(--tw-bg-opacity)); +} + +.bg-gray-300 { + --tw-bg-opacity: 1; + background-color: rgb(209 213 219 / var(--tw-bg-opacity)); +} + +.bg-green\/10 { + background-color: rgb(90 213 153 / 0.1); +} + +.bg-green\/40 { + background-color: rgb(90 213 153 / 0.4); +} + +.bg-green\/70 { + background-color: rgb(90 213 153 / 0.7); +} + +.bg-orange { + --tw-bg-opacity: 1; + background-color: rgb(255 159 0 / var(--tw-bg-opacity)); } .bg-red-500 { @@ -1841,19 +1881,20 @@ input[type=file] { background-color: rgb(239 68 68 / var(--tw-bg-opacity)); } -.bg-green\/50 { - background-color: rgb(90 213 153 / 0.5); -} - -.bg-black { +.bg-red-600 { --tw-bg-opacity: 1; - background-color: rgb(5 26 38 / var(--tw-bg-opacity)); + background-color: rgb(220 38 38 / var(--tw-bg-opacity)); } .bg-transparent { background-color: transparent; } +.bg-white { + --tw-bg-opacity: 1; + background-color: rgb(255 255 255 / var(--tw-bg-opacity)); +} + .bg-opacity-70 { --tw-bg-opacity: 0.7; } @@ -1863,23 +1904,23 @@ input[type=file] { } .from-green { - --tw-gradient-from: #5AD599; - --tw-gradient-to: rgb(90 213 153 / 0); + --tw-gradient-from: #5AD599 var(--tw-gradient-from-position); + --tw-gradient-to: rgb(90 213 153 / 0) var(--tw-gradient-to-position); --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); } .to-orange { - --tw-gradient-to: #FF9F00; -} - -.fill-gold { - fill: #F4CA1F; + --tw-gradient-to: #FF9F00 var(--tw-gradient-to-position); } .fill-bronze { fill: #BB8A56; } +.fill-gold { + fill: #F4CA1F; +} + .fill-silver { fill: #B5C9D3; } @@ -1889,68 +1930,42 @@ input[type=file] { object-fit: cover; } -.p-6 { - padding: 1.5rem; -} - -.p-4 { - padding: 1rem; -} - -.p-3 { - padding: 0.75rem; +.object-right { + -o-object-position: right; + object-position: right; } .p-0 { padding: 0px; } -.p-2 { - padding: 0.5rem; -} - .p-1 { padding: 0.25rem; } -.px-4 { - padding-left: 1rem; - padding-right: 1rem; +.p-2 { + padding: 0.5rem; } -.py-3 { - padding-top: 0.75rem; - padding-bottom: 0.75rem; +.p-3 { + padding: 0.75rem; } -.px-6 { - padding-left: 1.5rem; - padding-right: 1.5rem; +.p-4 { + padding: 1rem; } -.py-1 { - padding-top: 0.25rem; - padding-bottom: 0.25rem; +.p-5 { + padding: 1.25rem; } -.px-8 { - padding-left: 2rem; - padding-right: 2rem; +.p-6 { + padding: 1.5rem; } -.py-4 { - padding-top: 1rem; - padding-bottom: 1rem; -} - -.px-3 { - padding-left: 0.75rem; - padding-right: 0.75rem; -} - -.py-5 { - padding-top: 1.25rem; - padding-bottom: 1.25rem; +.px-11 { + padding-left: 2.75rem; + padding-right: 2.75rem; } .px-2 { @@ -1958,9 +1973,34 @@ input[type=file] { padding-right: 0.5rem; } -.py-11 { - padding-top: 2.75rem; - padding-bottom: 2.75rem; +.px-3 { + padding-left: 0.75rem; + padding-right: 0.75rem; +} + +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} + +.px-5 { + padding-left: 1.25rem; + padding-right: 1.25rem; +} + +.px-6 { + padding-left: 1.5rem; + padding-right: 1.5rem; +} + +.px-8 { + padding-left: 2rem; + padding-right: 2rem; +} + +.px-\[40px\] { + padding-left: 40px; + padding-right: 40px; } .py-0 { @@ -1968,6 +2008,46 @@ input[type=file] { padding-bottom: 0px; } +.py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +.py-11 { + padding-top: 2.75rem; + padding-bottom: 2.75rem; +} + +.py-14 { + padding-top: 3.5rem; + padding-bottom: 3.5rem; +} + +.py-16 { + padding-top: 4rem; + padding-bottom: 4rem; +} + +.py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.py-3 { + padding-top: 0.75rem; + padding-bottom: 0.75rem; +} + +.py-4 { + padding-top: 1rem; + padding-bottom: 1rem; +} + +.py-5 { + padding-top: 1.25rem; + padding-bottom: 1.25rem; +} + .py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; @@ -1978,40 +2058,58 @@ input[type=file] { padding-bottom: 2rem; } -.py-2 { - padding-top: 0.5rem; - padding-bottom: 0.5rem; +.pb-1 { + padding-bottom: 0.25rem; } -.py-16 { - padding-top: 4rem; +.pb-16 { padding-bottom: 4rem; } -.px-5 { - padding-left: 1.25rem; - padding-right: 1.25rem; +.pb-2 { + padding-bottom: 0.5rem; } -.px-\[40px\] { - padding-left: 40px; - padding-right: 40px; +.pb-3 { + padding-bottom: 0.75rem; } -.px-11 { - padding-left: 2.75rem; - padding-right: 2.75rem; +.pb-4 { + padding-bottom: 1rem; } -.py-14 { - padding-top: 3.5rem; - padding-bottom: 3.5rem; +.pb-6 { + padding-bottom: 1.5rem; +} + +.pl-0 { + padding-left: 0px; } .pl-11 { padding-left: 2.75rem; } +.pl-2 { + padding-left: 0.5rem; +} + +.pl-4 { + padding-left: 1rem; +} + +.pl-5 { + padding-left: 1.25rem; +} + +.pl-6 { + padding-left: 1.5rem; +} + +.pr-0 { + padding-right: 0px; +} + .pr-11 { padding-right: 2.75rem; } @@ -2020,84 +2118,12 @@ input[type=file] { padding-right: 0.5rem; } -.pl-2 { - padding-left: 0.5rem; -} - -.pb-3 { - padding-bottom: 0.75rem; -} - -.pt-6 { - padding-top: 1.5rem; -} - -.pr-4 { - padding-right: 1rem; -} - -.pl-4 { - padding-left: 1rem; -} - -.pl-0 { - padding-left: 0px; -} - -.pr-0 { - padding-right: 0px; -} - -.pt-1 { - padding-top: 0.25rem; -} - .pr-3 { padding-right: 0.75rem; } -.pt-4 { - padding-top: 1rem; -} - -.pt-3 { - padding-top: 0.75rem; -} - -.pb-11 { - padding-bottom: 2.75rem; -} - -.pt-5 { - padding-top: 1.25rem; -} - -.pl-6 { - padding-left: 1.5rem; -} - -.pb-2 { - padding-bottom: 0.5rem; -} - -.pt-2 { - padding-top: 0.5rem; -} - -.pb-1 { - padding-bottom: 0.25rem; -} - -.pb-16 { - padding-bottom: 4rem; -} - -.pb-6 { - padding-bottom: 1.5rem; -} - -.pl-5 { - padding-left: 1.25rem; +.pr-4 { + padding-right: 1rem; } .pr-8 { @@ -2108,8 +2134,28 @@ input[type=file] { padding-top: 0px; } -.pb-4 { - padding-bottom: 1rem; +.pt-1 { + padding-top: 0.25rem; +} + +.pt-2 { + padding-top: 0.5rem; +} + +.pt-3 { + padding-top: 0.75rem; +} + +.pt-4 { + padding-top: 1rem; +} + +.pt-5 { + padding-top: 1.25rem; +} + +.pt-6 { + padding-top: 1.5rem; } .text-left { @@ -2136,14 +2182,34 @@ input[type=file] { font-family: 'Cairo', sans-serif; } +.text-2xl { + font-size: 1.5rem; + line-height: 2rem; +} + +.text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; +} + +.text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; +} + .text-5xl { font-size: 3rem; line-height: 1; } -.text-2xl { - font-size: 1.5rem; - line-height: 2rem; +.text-base { + font-size: 1rem; + line-height: 1.5rem; +} + +.text-lg { + font-size: 1.125rem; + line-height: 1.75rem; } .text-sm { @@ -2156,31 +2222,11 @@ input[type=file] { line-height: 1.75rem; } -.text-base { - font-size: 1rem; - line-height: 1.5rem; -} - -.text-4xl { - font-size: 2.25rem; - line-height: 2.5rem; -} - -.text-lg { - font-size: 1.125rem; - line-height: 1.75rem; -} - .text-xs { font-size: 0.75rem; line-height: 1rem; } -.text-3xl { - font-size: 1.875rem; - line-height: 2.25rem; -} - .font-bold { font-weight: 700; } @@ -2189,6 +2235,10 @@ input[type=file] { font-weight: 800; } +.font-light { + font-weight: 300; +} + .font-medium { font-weight: 500; } @@ -2197,14 +2247,6 @@ input[type=file] { font-weight: 600; } -.font-thin { - font-weight: 100; -} - -.font-light { - font-weight: 300; -} - .uppercase { text-transform: uppercase; } @@ -2221,90 +2263,28 @@ input[type=file] { line-height: 1.25; } -.tracking-tight { - letter-spacing: -0.025em; -} - .tracking-wider { letter-spacing: 0.05em; } -.text-white { - --tw-text-opacity: 1; - color: rgb(255 255 255 / var(--tw-text-opacity)); -} - -.text-slate { - --tw-text-opacity: 1; - color: rgb(49 74 87 / var(--tw-text-opacity)); -} - -.text-orange { - --tw-text-opacity: 1; - color: rgb(255 159 0 / var(--tw-text-opacity)); -} - -.text-green { - --tw-text-opacity: 1; - color: rgb(90 213 153 / var(--tw-text-opacity)); -} - -.text-white\/60 { - color: rgb(255 255 255 / 0.6); -} - -.text-charcoal { - --tw-text-opacity: 1; - color: rgb(23 42 52 / var(--tw-text-opacity)); -} - -.text-sky-500 { - --tw-text-opacity: 1; - color: rgb(14 165 233 / var(--tw-text-opacity)); -} - -.text-gold { - --tw-text-opacity: 1; - color: rgb(244 202 31 / var(--tw-text-opacity)); -} - -.text-sky-600 { - --tw-text-opacity: 1; - color: rgb(2 132 199 / var(--tw-text-opacity)); -} - -.text-gray-700 { - --tw-text-opacity: 1; - color: rgb(55 65 81 / var(--tw-text-opacity)); -} - -.text-gray-400 { - --tw-text-opacity: 1; - color: rgb(156 163 175 / var(--tw-text-opacity)); -} - -.text-sky-400 { - --tw-text-opacity: 1; - color: rgb(56 189 248 / var(--tw-text-opacity)); -} - -.text-gray-100 { - --tw-text-opacity: 1; - color: rgb(243 244 246 / var(--tw-text-opacity)); -} - .text-bronze { --tw-text-opacity: 1; color: rgb(187 138 86 / var(--tw-text-opacity)); } -.text-silver { +.text-charcoal { --tw-text-opacity: 1; - color: rgb(181 201 211 / var(--tw-text-opacity)); + color: rgb(23 42 52 / var(--tw-text-opacity)); } -.text-white\/70 { - color: rgb(255 255 255 / 0.7); +.text-gold { + --tw-text-opacity: 1; + color: rgb(244 202 31 / var(--tw-text-opacity)); +} + +.text-gray-100 { + --tw-text-opacity: 1; + color: rgb(243 244 246 / var(--tw-text-opacity)); } .text-gray-200 { @@ -2312,6 +2292,64 @@ input[type=file] { color: rgb(229 231 235 / var(--tw-text-opacity)); } +.text-gray-600 { + --tw-text-opacity: 1; + color: rgb(75 85 99 / var(--tw-text-opacity)); +} + +.text-gray-700 { + --tw-text-opacity: 1; + color: rgb(55 65 81 / var(--tw-text-opacity)); +} + +.text-green { + --tw-text-opacity: 1; + color: rgb(90 213 153 / var(--tw-text-opacity)); +} + +.text-orange { + --tw-text-opacity: 1; + color: rgb(255 159 0 / var(--tw-text-opacity)); +} + +.text-silver { + --tw-text-opacity: 1; + color: rgb(181 201 211 / var(--tw-text-opacity)); +} + +.text-sky-400 { + --tw-text-opacity: 1; + color: rgb(56 189 248 / var(--tw-text-opacity)); +} + +.text-sky-500 { + --tw-text-opacity: 1; + color: rgb(14 165 233 / var(--tw-text-opacity)); +} + +.text-sky-600 { + --tw-text-opacity: 1; + color: rgb(2 132 199 / var(--tw-text-opacity)); +} + +.text-slate { + --tw-text-opacity: 1; + color: rgb(49 74 87 / var(--tw-text-opacity)); +} + +.text-white { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +.text-white\/60 { + color: rgb(255 255 255 / 0.6); +} + +.text-white\/70 { + color: rgb(255 255 255 / 0.7); +} + .placeholder-gray-500::-moz-placeholder { --tw-placeholder-opacity: 1; color: rgb(107 114 128 / var(--tw-placeholder-opacity)); @@ -2330,18 +2368,6 @@ input[type=file] { opacity: 1; } -.shadow-lg { - --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); - --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); - box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); -} - -.shadow-md { - --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); - --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color); - box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); -} - .shadow { --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); @@ -2354,6 +2380,12 @@ input[type=file] { box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); } +.shadow-lg { + --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + .ring-1 { --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); @@ -2373,14 +2405,14 @@ input[type=file] { transition-duration: 150ms; } -.transition-opacity { - transition-property: opacity; +.transition-all { + transition-property: all; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; } -.transition-all { - transition-property: all; +.transition-opacity { + transition-property: opacity; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; } @@ -2393,14 +2425,14 @@ input[type=file] { transition-duration: 75ms; } -.ease-out { - transition-timing-function: cubic-bezier(0, 0, 0.2, 1); -} - .ease-in { transition-timing-function: cubic-bezier(0.4, 0, 1, 1); } +.ease-out { + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); +} + .last\:border-0:last-child { border-width: 0px; } @@ -2410,6 +2442,11 @@ input[type=file] { background-color: rgb(255 159 0 / var(--tw-bg-opacity)); } +.hover\:bg-gray-100:hover { + --tw-bg-opacity: 1; + background-color: rgb(243 244 246 / var(--tw-bg-opacity)); +} + .hover\:bg-gray-50:hover { --tw-bg-opacity: 1; background-color: rgb(249 250 251 / var(--tw-bg-opacity)); @@ -2425,11 +2462,6 @@ input[type=file] { background-color: rgb(49 74 87 / var(--tw-bg-opacity)); } -.hover\:text-orange:hover { - --tw-text-opacity: 1; - color: rgb(255 159 0 / var(--tw-text-opacity)); -} - .hover\:text-gold:hover { --tw-text-opacity: 1; color: rgb(244 202 31 / var(--tw-text-opacity)); @@ -2439,6 +2471,11 @@ input[type=file] { color: rgb(244 202 31 / 0.6); } +.hover\:text-orange:hover { + --tw-text-opacity: 1; + color: rgb(255 159 0 / var(--tw-text-opacity)); +} + .hover\:text-white:hover { --tw-text-opacity: 1; color: rgb(255 255 255 / var(--tw-text-opacity)); @@ -2465,131 +2502,132 @@ input[type=file] { color: rgb(255 159 0 / var(--tw-text-opacity)); } -.dark .dark\:inline-block { +:is(.dark .dark\:inline-block) { display: inline-block; } -.dark .dark\:inline { +:is(.dark .dark\:inline) { display: inline; } -.dark .dark\:hidden { +:is(.dark .dark\:hidden) { display: none; } -.dark .dark\:divide-slate > :not([hidden]) ~ :not([hidden]) { +:is(.dark .dark\:divide-slate) > :not([hidden]) ~ :not([hidden]) { --tw-divide-opacity: 1; border-color: rgb(49 74 87 / var(--tw-divide-opacity)); } -.dark .dark\:border-slate { +:is(.dark .dark\:border-charcoal) { + --tw-border-opacity: 1; + border-color: rgb(23 42 52 / var(--tw-border-opacity)); +} + +:is(.dark .dark\:border-slate) { --tw-border-opacity: 1; border-color: rgb(49 74 87 / var(--tw-border-opacity)); } -.dark .dark\:bg-black { +:is(.dark .dark\:bg-black) { --tw-bg-opacity: 1; background-color: rgb(5 26 38 / var(--tw-bg-opacity)); } -.dark .dark\:bg-charcoal { +:is(.dark .dark\:bg-charcoal) { --tw-bg-opacity: 1; background-color: rgb(23 42 52 / var(--tw-bg-opacity)); } -.dark .dark\:bg-green\/10 { - background-color: rgb(90 213 153 / 0.1); +:is(.dark .dark\:bg-green\/40) { + background-color: rgb(90 213 153 / 0.4); } -.dark .dark\:bg-slate { +:is(.dark .dark\:bg-slate) { --tw-bg-opacity: 1; background-color: rgb(49 74 87 / var(--tw-bg-opacity)); } -.dark .dark\:font-medium { +:is(.dark .dark\:font-medium) { font-weight: 500; } -.dark .dark\:text-white { - --tw-text-opacity: 1; - color: rgb(255 255 255 / var(--tw-text-opacity)); +:is(.dark .dark\:text-blue-300\/60) { + color: rgb(147 197 253 / 0.6); } -.dark .dark\:text-orange { - --tw-text-opacity: 1; - color: rgb(255 159 0 / var(--tw-text-opacity)); -} - -.dark .dark\:text-white\/70 { - color: rgb(255 255 255 / 0.7); -} - -.dark .dark\:text-white\/50 { - color: rgb(255 255 255 / 0.5); -} - -.dark .dark\:text-charcoal { +:is(.dark .dark\:text-charcoal) { --tw-text-opacity: 1; color: rgb(23 42 52 / var(--tw-text-opacity)); } -.dark .dark\:text-white\/40 { - color: rgb(255 255 255 / 0.4); -} - -.dark .dark\:text-sky-600 { - --tw-text-opacity: 1; - color: rgb(2 132 199 / var(--tw-text-opacity)); -} - -.dark .dark\:text-white\/60 { - color: rgb(255 255 255 / 0.6); -} - -.dark .dark\:text-gray-300 { +:is(.dark .dark\:text-gray-300) { --tw-text-opacity: 1; color: rgb(209 213 219 / var(--tw-text-opacity)); } -.dark .dark\:text-gray-400 { +:is(.dark .dark\:text-gray-400) { --tw-text-opacity: 1; color: rgb(156 163 175 / var(--tw-text-opacity)); } -.dark .dark\:text-blue-300\/60 { - color: rgb(147 197 253 / 0.6); -} - -.dark .dark\:ring-gray-500 { - --tw-ring-opacity: 1; - --tw-ring-color: rgb(107 114 128 / var(--tw-ring-opacity)); -} - -.dark .dark\:hover\:text-orange:hover { +:is(.dark .dark\:text-orange) { --tw-text-opacity: 1; color: rgb(255 159 0 / var(--tw-text-opacity)); } -.dark .dark\:hover\:text-gold:hover { +:is(.dark .dark\:text-sky-600) { + --tw-text-opacity: 1; + color: rgb(2 132 199 / var(--tw-text-opacity)); +} + +:is(.dark .dark\:text-white) { + --tw-text-opacity: 1; + color: rgb(255 255 255 / var(--tw-text-opacity)); +} + +:is(.dark .dark\:text-white\/50) { + color: rgb(255 255 255 / 0.5); +} + +:is(.dark .dark\:text-white\/60) { + color: rgb(255 255 255 / 0.6); +} + +:is(.dark .dark\:text-white\/70) { + color: rgb(255 255 255 / 0.7); +} + +:is(.dark .dark\:ring-gray-500) { + --tw-ring-opacity: 1; + --tw-ring-color: rgb(107 114 128 / var(--tw-ring-opacity)); +} + +:is(.dark .dark\:hover\:text-gold:hover) { --tw-text-opacity: 1; color: rgb(244 202 31 / var(--tw-text-opacity)); } -.dark .dark\:hover\:text-gold\/80:hover { +:is(.dark .dark\:hover\:text-gold\/80:hover) { color: rgb(244 202 31 / 0.8); } +:is(.dark .dark\:hover\:text-orange:hover) { + --tw-text-opacity: 1; + color: rgb(255 159 0 / var(--tw-text-opacity)); +} + @media (min-width: 640px) { - .sm\:mt-24 { - margin-top: 6rem; + .sm\:ml-3 { + margin-left: 0.75rem; } .sm\:mt-0 { margin-top: 0px; } - .sm\:ml-3 { - margin-left: 0.75rem; + .sm\:mt-24 { + margin-top: 6rem; } .sm\:block { @@ -2604,14 +2642,14 @@ input[type=file] { display: flex; } - .sm\:w-3\/4 { - width: 75%; - } - .sm\:w-1\/4 { width: 25%; } + .sm\:w-3\/4 { + width: 75%; + } + .sm\:space-y-11 > :not([hidden]) ~ :not([hidden]) { --tw-space-y-reverse: 0; margin-top: calc(2.75rem * calc(1 - var(--tw-space-y-reverse))); @@ -2627,11 +2665,6 @@ input[type=file] { padding-right: 3.5rem; } - .sm\:text-5xl { - font-size: 3rem; - line-height: 1; - } - .sm\:text-sm { font-size: 0.875rem; line-height: 1.25rem; @@ -2639,38 +2672,20 @@ input[type=file] { } @media (min-width: 768px) { - .md\:container { - width: 100%; + .md\:fixed { + position: fixed; } - @media (min-width: 640px) { - .md\:container { - max-width: 640px; - } + .md\:left-0 { + left: 0px; } - @media (min-width: 768px) { - .md\:container { - max-width: 768px; - } + .md\:right-0 { + right: 0px; } - @media (min-width: 1024px) { - .md\:container { - max-width: 1024px; - } - } - - @media (min-width: 1280px) { - .md\:container { - max-width: 1280px; - } - } - - @media (min-width: 1536px) { - .md\:container { - max-width: 1536px; - } + .md\:top-0 { + top: 0px; } .md\:order-2 { @@ -2685,66 +2700,64 @@ input[type=file] { float: right; } - .md\:my-16 { - margin-top: 4rem; - margin-bottom: 4rem; - } - - .md\:my-0 { - margin-top: 0px; - margin-bottom: 0px; - } - - .md\:my-6 { - margin-top: 1.5rem; - margin-bottom: 1.5rem; - } - .md\:mx-0 { margin-left: 0px; margin-right: 0px; } - .md\:my-11 { - margin-top: 2.75rem; - margin-bottom: 2.75rem; - } - .md\:mx-auto { margin-left: auto; margin-right: auto; } - .md\:mt-3 { - margin-top: 0.75rem; + .md\:my-0 { + margin-top: 0px; + margin-bottom: 0px; } - .md\:mt-5 { - margin-top: 1.25rem; - } - - .md\:mb-11 { - margin-bottom: 2.75rem; - } - - .md\:mt-8 { - margin-top: 2rem; + .md\:my-16 { + margin-top: 4rem; + margin-bottom: 4rem; } .md\:mb-0 { margin-bottom: 0px; } - .md\:mt-11 { - margin-top: 2.75rem; + .md\:mb-11 { + margin-bottom: 2.75rem; + } + + .md\:mb-6 { + margin-bottom: 1.5rem; } .md\:mt-0 { margin-top: 0px; } - .md\:mb-6 { - margin-bottom: 1.5rem; + .md\:mt-11 { + margin-top: 2.75rem; + } + + .md\:mt-3 { + margin-top: 0.75rem; + } + + .md\:mt-32 { + margin-top: 8rem; + } + + .md\:mt-5 { + margin-top: 1.25rem; + } + + .md\:mt-6 { + margin-top: 1.5rem; + } + + .md\:mt-8 { + margin-top: 2rem; } .md\:block { @@ -2771,60 +2784,52 @@ input[type=file] { width: 50%; } - .md\:w-48 { - width: 12rem; - } - - .md\:w-full { - width: 100%; - } - - .md\:w-5\/6 { - width: 83.333333%; - } - - .md\:w-2\/3 { - width: 66.666667%; - } - - .md\:w-1\/6 { - width: 16.666667%; - } - - .md\:w-1\/5 { - width: 20%; - } - - .md\:w-3\/5 { - width: 60%; + .md\:w-1\/3 { + width: 33.333333%; } .md\:w-1\/4 { width: 25%; } + .md\:w-1\/5 { + width: 20%; + } + + .md\:w-1\/6 { + width: 16.666667%; + } + + .md\:w-2\/3 { + width: 66.666667%; + } + .md\:w-3\/4 { width: 75%; } - .md\:w-1\/3 { - width: 33.333333%; + .md\:w-3\/5 { + width: 60%; + } + + .md\:w-5\/6 { + width: 83.333333%; } .md\:w-auto { width: auto; } - .md\:max-w-7xl { - max-width: 80rem; + .md\:w-full { + width: 100%; } .md\:max-w-3xl { max-width: 48rem; } - .md\:grid-cols-6 { - grid-template-columns: repeat(6, minmax(0, 1fr)); + .md\:max-w-7xl { + max-width: 80rem; } .md\:grid-cols-2 { @@ -2835,6 +2840,10 @@ input[type=file] { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .md\:grid-cols-6 { + grid-template-columns: repeat(6, minmax(0, 1fr)); + } + .md\:flex-row { flex-direction: row; } @@ -2843,10 +2852,16 @@ input[type=file] { justify-content: space-between; } - .md\:space-y-0 > :not([hidden]) ~ :not([hidden]) { - --tw-space-y-reverse: 0; - margin-top: calc(0px * calc(1 - var(--tw-space-y-reverse))); - margin-bottom: calc(0px * var(--tw-space-y-reverse)); + .md\:space-x-10 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(2.5rem * var(--tw-space-x-reverse)); + margin-left: calc(2.5rem * calc(1 - var(--tw-space-x-reverse))); + } + + .md\:space-x-3 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(0.75rem * var(--tw-space-x-reverse)); + margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse))); } .md\:space-x-4 > :not([hidden]) ~ :not([hidden]) { @@ -2861,36 +2876,18 @@ input[type=file] { margin-left: calc(1.5rem * calc(1 - var(--tw-space-x-reverse))); } - .md\:space-x-10 > :not([hidden]) ~ :not([hidden]) { - --tw-space-x-reverse: 0; - margin-right: calc(2.5rem * var(--tw-space-x-reverse)); - margin-left: calc(2.5rem * calc(1 - var(--tw-space-x-reverse))); - } - - .md\:space-x-5 > :not([hidden]) ~ :not([hidden]) { - --tw-space-x-reverse: 0; - margin-right: calc(1.25rem * var(--tw-space-x-reverse)); - margin-left: calc(1.25rem * calc(1 - var(--tw-space-x-reverse))); - } - - .md\:space-x-3 > :not([hidden]) ~ :not([hidden]) { - --tw-space-x-reverse: 0; - margin-right: calc(0.75rem * var(--tw-space-x-reverse)); - margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse))); - } - - .md\:space-x-11 > :not([hidden]) ~ :not([hidden]) { - --tw-space-x-reverse: 0; - margin-right: calc(2.75rem * var(--tw-space-x-reverse)); - margin-left: calc(2.75rem * calc(1 - var(--tw-space-x-reverse))); - } - .md\:space-x-8 > :not([hidden]) ~ :not([hidden]) { --tw-space-x-reverse: 0; margin-right: calc(2rem * var(--tw-space-x-reverse)); margin-left: calc(2rem * calc(1 - var(--tw-space-x-reverse))); } + .md\:space-y-0 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(0px * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(0px * var(--tw-space-y-reverse)); + } + .md\:divide-x-2 > :not([hidden]) ~ :not([hidden]) { --tw-divide-x-reverse: 0; border-right-width: calc(2px * var(--tw-divide-x-reverse)); @@ -2919,10 +2916,6 @@ input[type=file] { border-width: 0px; } - .md\:border-t-0 { - border-top-width: 0px; - } - .md\:border-b { border-bottom-width: 1px; } @@ -2931,6 +2924,10 @@ input[type=file] { border-bottom-width: 0px; } + .md\:border-t-0 { + border-top-width: 0px; + } + .md\:border-orange { --tw-border-opacity: 1; border-color: rgb(255 159 0 / var(--tw-border-opacity)); @@ -2941,45 +2938,20 @@ input[type=file] { border-color: rgb(49 74 87 / var(--tw-border-opacity)); } - .md\:p-11 { - padding: 2.75rem; - } - .md\:p-0 { padding: 0px; } - .md\:p-5 { - padding: 1.25rem; + .md\:p-11 { + padding: 2.75rem; } .md\:p-20 { padding: 5rem; } - .md\:px-11 { - padding-left: 2.75rem; - padding-right: 2.75rem; - } - - .md\:py-4 { - padding-top: 1rem; - padding-bottom: 1rem; - } - - .md\:px-4 { - padding-left: 1rem; - padding-right: 1rem; - } - - .md\:px-10 { - padding-left: 2.5rem; - padding-right: 2.5rem; - } - - .md\:py-6 { - padding-top: 1.5rem; - padding-bottom: 1.5rem; + .md\:p-5 { + padding: 1.25rem; } .md\:px-0 { @@ -2987,29 +2959,24 @@ input[type=file] { padding-right: 0px; } - .md\:py-1 { - padding-top: 0.25rem; - padding-bottom: 0.25rem; + .md\:px-10 { + padding-left: 2.5rem; + padding-right: 2.5rem; } - .md\:py-2 { - padding-top: 0.5rem; - padding-bottom: 0.5rem; + .md\:px-11 { + padding-left: 2.75rem; + padding-right: 2.75rem; } - .md\:py-11 { - padding-top: 2.75rem; - padding-bottom: 2.75rem; + .md\:px-4 { + padding-left: 1rem; + padding-right: 1rem; } - .md\:py-3 { - padding-top: 0.75rem; - padding-bottom: 0.75rem; - } - - .md\:px-8 { - padding-left: 2rem; - padding-right: 2rem; + .md\:px-6 { + padding-left: 1.5rem; + padding-right: 1.5rem; } .md\:py-0 { @@ -3017,20 +2984,53 @@ input[type=file] { padding-bottom: 0px; } - .md\:pl-6 { - padding-left: 1.5rem; + .md\:py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; } - .md\:pt-11 { + .md\:py-11 { padding-top: 2.75rem; + padding-bottom: 2.75rem; + } + + .md\:py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; + } + + .md\:py-4 { + padding-top: 1rem; + padding-bottom: 1rem; + } + + .md\:py-6 { + padding-top: 1.5rem; + padding-bottom: 1.5rem; + } + + .md\:pb-11 { + padding-bottom: 2.75rem; + } + + .md\:pl-6 { + padding-left: 1.5rem; } .md\:pr-11 { padding-right: 2.75rem; } - .md\:pb-6 { - padding-bottom: 1.5rem; + .md\:pt-11 { + padding-top: 2.75rem; + } + + .md\:pt-4 { + padding-top: 1rem; + } + + .md\:pt-6 { + padding-top: 1.5rem; } .md\:text-right { @@ -3042,9 +3042,9 @@ input[type=file] { line-height: 1; } - .md\:text-xl { - font-size: 1.25rem; - line-height: 1.75rem; + .md\:text-base { + font-size: 1rem; + line-height: 1.5rem; } .md\:text-lg { @@ -3052,51 +3052,13 @@ input[type=file] { line-height: 1.75rem; } - .md\:text-base { - font-size: 1rem; - line-height: 1.5rem; + .md\:text-xl { + font-size: 1.25rem; + line-height: 1.75rem; } } @media (min-width: 1024px) { - .lg\:container { - width: 100%; - } - - @media (min-width: 640px) { - .lg\:container { - max-width: 640px; - } - } - - @media (min-width: 768px) { - .lg\:container { - max-width: 768px; - } - } - - @media (min-width: 1024px) { - .lg\:container { - max-width: 1024px; - } - } - - @media (min-width: 1280px) { - .lg\:container { - max-width: 1280px; - } - } - - @media (min-width: 1536px) { - .lg\:container { - max-width: 1536px; - } - } - - .lg\:max-w-7xl { - max-width: 80rem; - } - .lg\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); } @@ -3106,43 +3068,15 @@ input[type=file] { margin-right: calc(2.5rem * var(--tw-space-x-reverse)); margin-left: calc(2.5rem * calc(1 - var(--tw-space-x-reverse))); } + + .lg\:space-x-5 > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(1.25rem * var(--tw-space-x-reverse)); + margin-left: calc(1.25rem * calc(1 - var(--tw-space-x-reverse))); + } } @media (min-width: 1280px) { - .xl\:container { - width: 100%; - } - - @media (min-width: 640px) { - .xl\:container { - max-width: 640px; - } - } - - @media (min-width: 768px) { - .xl\:container { - max-width: 768px; - } - } - - @media (min-width: 1024px) { - .xl\:container { - max-width: 1024px; - } - } - - @media (min-width: 1280px) { - .xl\:container { - max-width: 1280px; - } - } - - @media (min-width: 1536px) { - .xl\:container { - max-width: 1536px; - } - } - .xl\:inline { display: inline; } @@ -3151,9 +3085,9 @@ input[type=file] { grid-template-columns: repeat(3, minmax(0, 1fr)); } - .xl\:space-x-10 > :not([hidden]) ~ :not([hidden]) { + .xl\:space-x-6 > :not([hidden]) ~ :not([hidden]) { --tw-space-x-reverse: 0; - margin-right: calc(2.5rem * var(--tw-space-x-reverse)); - margin-left: calc(2.5rem * calc(1 - var(--tw-space-x-reverse))); + margin-right: calc(1.5rem * var(--tw-space-x-reverse)); + margin-left: calc(1.5rem * calc(1 - var(--tw-space-x-reverse))); } } diff --git a/static/img/fpo/boost-release-package.png b/static/img/fpo/boost-release-package.png new file mode 100644 index 00000000..fb020368 Binary files /dev/null and b/static/img/fpo/boost-release-package.png differ diff --git a/static/img/fpo/contributor_guide.png b/static/img/fpo/contributor_guide.png new file mode 100644 index 00000000..42600645 Binary files /dev/null and b/static/img/fpo/contributor_guide.png differ diff --git a/static/img/fpo/formal_reviews.png b/static/img/fpo/formal_reviews.png new file mode 100644 index 00000000..c0fe6e5c Binary files /dev/null and b/static/img/fpo/formal_reviews.png differ diff --git a/static/img/fpo/release_process.png b/static/img/fpo/release_process.png new file mode 100644 index 00000000..6a424260 Binary files /dev/null and b/static/img/fpo/release_process.png differ diff --git a/static/img/fpo/user_guide.png b/static/img/fpo/user_guide.png new file mode 100644 index 00000000..9ea9ed27 Binary files /dev/null and b/static/img/fpo/user_guide.png differ diff --git a/templates/admin/base_site.html b/templates/admin/base_site.html index 33d6a9b2..6833972e 100644 --- a/templates/admin/base_site.html +++ b/templates/admin/base_site.html @@ -1,7 +1,7 @@ {% extends "admin/base.html" %} {% load i18n %} -{% block title %}{{ title }} | {% trans 'boost.org admin' %}{% endblock %} +{% block title %}{{ title }} | {% trans 'boost.revsys.dev admin' %}{% endblock %} {% block extrastyle %}{{ block.super }} {% if ENVIRONMENT_NAME and ENVIRONMENT_COLOR %} @@ -27,7 +27,7 @@ {% endblock %} {% block branding %} -

{% trans 'boost.org administration' %}

+

{% trans 'boost.revsys.dev administration' %}

{% endblock %} -{% block nav-global %}{% endblock %} \ No newline at end of file +{% block nav-global %}{% endblock %} diff --git a/templates/base.html b/templates/base.html index 21e17a01..de35f134 100644 --- a/templates/base.html +++ b/templates/base.html @@ -3,13 +3,25 @@ - {% block title %}{% endblock %} + + + + + {% block title %}Boost{% endblock %} + + @@ -17,6 +29,8 @@ + + {% block extra_head %}{% endblock %} @@ -39,8 +53,8 @@ if (m !== 'dark' && m !== 'light') return; mode = m; }" - class="h-screen dark:bg-black bg-stone/60 font-cairo" {% block body_id %}{% endblock %}> -
+ class="h-screen bg-gray-200 dark:bg-black font-cairo" {% block body_id %}{% endblock %}> +
{% include "includes/_header.html" %} @@ -51,7 +65,7 @@ {% comment %}body block is for use in forums{% endcomment %} {% block forum_body %}{% endblock %} -
+
{% block content_wrapper %} {% block subnav %}{% endblock %} {% block content %}{% endblock %} diff --git a/templates/community_temp.html b/templates/community_temp.html new file mode 100644 index 00000000..9bf0a3d2 --- /dev/null +++ b/templates/community_temp.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% load i18n %} +{% load static %} + +{% block title %}{% trans "Community" %}{% endblock %} + +{% block content %} +
+
+
+
Mailing List
+
    +
  • Users
  • +
  • Developers
  • +
  • Announcements
  • +
+
+ +
+
Discussion Forums
+

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud +exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.

+
+ +
+
C++ Slack Workspace
+

This is a multi-channel Slack workspace environment which gathers users of C++ from around the world into the same place so we can all learn from each other. It is like IRC but with modern clients that support multimedia, notifications, and smart devices.

+
+ +
+
Users
+

See everyone involved with Boost. (Under construction)

+
+ +
+
Twitter
+

Follow us, like, and retweet announcements and informative updates about Boost and C++.

+
+ +
+
ISO C++
+

News, Status & Discussion about Standard C++

+
+
+
+{% endblock %} diff --git a/templates/docs_temp.html b/templates/docs_temp.html index 2d2752da..3f9ea6fe 100644 --- a/templates/docs_temp.html +++ b/templates/docs_temp.html @@ -1,7 +1,9 @@ {% extends "base.html" %} - +{% load i18n %} {% load static %} +{% block title %}{% trans "Learn" %}{% endblock %} + {% comment %} This is a temporary landing page for docs {% endcomment %} @@ -12,12 +14,11 @@ This is a temporary landing page for docs
@@ -49,7 +43,7 @@ This is a temporary landing page for docs
@@ -57,20 +51,14 @@ This is a temporary landing page for docs class="text-xl font-bold leading-tight text-orange"> Contributor Guide -

This is how you can help

+

This is how you can help

@@ -80,13 +68,13 @@ This is a temporary landing page for docs
{# FIXME #} - + -
+
Boost Formal Reviews @@ -95,18 +83,14 @@ This is a temporary landing page for docs How libraries become part of the collection.

-
+
Release Process @@ -115,19 +99,14 @@ This is a temporary landing page for docs "The trains always run on time"

{# FIXME #} - +
diff --git a/templates/homepage.html b/templates/homepage.html index 8e990f6d..9fcb3f0d 100644 --- a/templates/homepage.html +++ b/templates/homepage.html @@ -4,7 +4,7 @@ {% block content %} -
+
@@ -15,10 +15,10 @@

Experience C++ libraries created by experts to be reliable, skillfully designed, and well-tested.

diff --git a/templates/includes/_footer.html b/templates/includes/_footer.html index 4378b4cb..827edbc7 100644 --- a/templates/includes/_footer.html +++ b/templates/includes/_footer.html @@ -1,51 +1,15 @@ {% load static %} -