diff --git a/swh/web/add_forge_now/__init__.py b/swh/web/add_forge_now/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/swh/web/add_forge_now/apps.py b/swh/web/add_forge_now/apps.py new file mode 100644 index 00000000..ef067ab4 --- /dev/null +++ b/swh/web/add_forge_now/apps.py @@ -0,0 +1,10 @@ +# Copyright (C) 2022 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU Affero General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from django.apps import AppConfig + + +class AddForgeNowConfig(AppConfig): + name = "add_forge_now" diff --git a/swh/web/add_forge_now/migrations/0001_initial.py b/swh/web/add_forge_now/migrations/0001_initial.py new file mode 100644 index 00000000..c049a13b --- /dev/null +++ b/swh/web/add_forge_now/migrations/0001_initial.py @@ -0,0 +1,109 @@ +# Generated by Django 2.2.24 on 2022-03-08 10:25 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] # type: ignore + + operations = [ + migrations.CreateModel( + name="Request", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "status", + models.TextField( + choices=[ + ("PENDING", "Pending"), + ("WAITING_FOR_FEEDBACK", "Waiting for feedback"), + ("FEEDBACK_TO_HANDLE", "Feedback to handle"), + ("ACCEPTED", "Accepted"), + ("SCHEDULED", "Scheduled"), + ("FIRST_LISTING_DONE", "First listing done"), + ("FIRST_ORIGIN_LOADED", "First origin loaded"), + ("REJECTED", "Rejected"), + ("SUSPENDED", "Suspended"), + ("DENIED", "Denied"), + ], + default="PENDING", + ), + ), + ("submission_date", models.DateTimeField(auto_now_add=True)), + ("submitter_name", models.TextField()), + ("submitter_email", models.TextField()), + ("forge_type", models.TextField()), + ("forge_url", models.TextField()), + ("forge_contact_email", models.EmailField(max_length=254)), + ("forge_contact_name", models.TextField()), + ( + "forge_contact_comment", + models.TextField( + help_text=( + "Where did you find this contact information (url, ...)" + ) + ), + ), + ], + ), + migrations.CreateModel( + name="RequestHistory", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("text", models.TextField()), + ("actor", models.TextField()), + ( + "actor_role", + models.TextField( + choices=[("MODERATOR", "moderator"), ("SUBMITTER", "submitter")] + ), + ), + ("date", models.DateTimeField(auto_now_add=True)), + ( + "new_status", + models.TextField( + choices=[ + ("PENDING", "Pending"), + ("WAITING_FOR_FEEDBACK", "Waiting for feedback"), + ("FEEDBACK_TO_HANDLE", "Feedback to handle"), + ("ACCEPTED", "Accepted"), + ("SCHEDULED", "Scheduled"), + ("FIRST_LISTING_DONE", "First listing done"), + ("FIRST_ORIGIN_LOADED", "First origin loaded"), + ("REJECTED", "Rejected"), + ("SUSPENDED", "Suspended"), + ("DENIED", "Denied"), + ], + null=True, + ), + ), + ( + "request", + models.ForeignKey( + on_delete=django.db.models.deletion.DO_NOTHING, + to="add_forge_now.Request", + ), + ), + ], + ), + ] diff --git a/swh/web/add_forge_now/migrations/__init__.py b/swh/web/add_forge_now/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/swh/web/add_forge_now/models.py b/swh/web/add_forge_now/models.py new file mode 100644 index 00000000..8683c440 --- /dev/null +++ b/swh/web/add_forge_now/models.py @@ -0,0 +1,71 @@ +# Copyright (C) 2022 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU Affero General Public License version 3, or any later version +# See top-level LICENSE file for more information + +import enum + +from django.db import models + + +class RequestStatus(enum.Enum): + """Request statuses. + + Values are used in the ui. + + """ + + PENDING = "Pending" + WAITING_FOR_FEEDBACK = "Waiting for feedback" + FEEDBACK_TO_HANDLE = "Feedback to handle" + ACCEPTED = "Accepted" + SCHEDULED = "Scheduled" + FIRST_LISTING_DONE = "First listing done" + FIRST_ORIGIN_LOADED = "First origin loaded" + REJECTED = "Rejected" + SUSPENDED = "Suspended" + DENIED = "Denied" + + @classmethod + def choices(cls): + return tuple((variant.name, variant.value) for variant in cls) + + +class RequestActorRole(enum.Enum): + MODERATOR = "moderator" + SUBMITTER = "submitter" + + @classmethod + def choices(cls): + return tuple((variant.name, variant.value) for variant in cls) + + +class RequestHistory(models.Model): + """Comment or status change. This is commented or changed by either submitter or + moderator. + + """ + + request = models.ForeignKey("Request", models.DO_NOTHING) + text = models.TextField() + actor = models.TextField() + actor_role = models.TextField(choices=RequestActorRole.choices()) + date = models.DateTimeField(auto_now_add=True) + new_status = models.TextField(choices=RequestStatus.choices(), null=True) + + +class Request(models.Model): + status = models.TextField( + choices=RequestStatus.choices(), default=RequestStatus.PENDING.name, + ) + submission_date = models.DateTimeField(auto_now_add=True) + submitter_name = models.TextField() + submitter_email = models.TextField() + # FIXME: shall we do create a user model inside the webapp instead? + forge_type = models.TextField() + forge_url = models.TextField() + forge_contact_email = models.EmailField() + forge_contact_name = models.TextField() + forge_contact_comment = models.TextField( + help_text="Where did you find this contact information (url, ...)", + ) diff --git a/swh/web/add_forge_now/tests/test_migration.py b/swh/web/add_forge_now/tests/test_migration.py new file mode 100644 index 00000000..04a84224 --- /dev/null +++ b/swh/web/add_forge_now/tests/test_migration.py @@ -0,0 +1,62 @@ +# Copyright (C) 2022 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from datetime import datetime, timezone + +APP_NAME = "add_forge_now" + +MIGRATION_0001 = "0001_initial" + + +def now() -> datetime: + return datetime.now(tz=timezone.utc) + + +def test_add_forge_now_initial_migration(migrator): + """Basic migration test to check the model is fine""" + + state = migrator.apply_tested_migration((APP_NAME, MIGRATION_0001)) + request = state.apps.get_model(APP_NAME, "Request") + request_history = state.apps.get_model(APP_NAME, "RequestHistory") + + from swh.web.add_forge_now.models import RequestActorRole, RequestStatus + + date_now = now() + + req = request( + status=RequestStatus.PENDING, + submitter_name="dudess", + submitter_email="dudess@orga.org", + forge_type="cgit", + forge_url="https://example.org/forge", + forge_contact_email="forge@//example.org", + forge_contact_name="forge", + forge_contact_comment=( + "Discovered on the main forge homepag, following contact link." + ), + ) + req.save() + + assert req.submission_date > date_now + + req_history = request_history( + request=req, + text="some comment from the moderator", + actor="moderator", + actor_role=RequestActorRole.MODERATOR, + new_status=None, + ) + req_history.save() + assert req_history.date > req.submission_date + + req_history2 = request_history( + request=req, + text="some answer from the user", + actor="user", + actor_role=RequestActorRole.SUBMITTER, + new_status=None, + ) + req_history2.save() + assert req_history2.date > req_history.date diff --git a/swh/web/settings/common.py b/swh/web/settings/common.py index e8ea4c11..4f2d1f96 100644 --- a/swh/web/settings/common.py +++ b/swh/web/settings/common.py @@ -1,292 +1,293 @@ # Copyright (C) 2017-2021 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information """ Django common settings for swh-web. """ import os import sys from typing import Any, Dict from swh.web.auth.utils import OIDC_SWH_WEB_CLIENT_ID from swh.web.config import get_config swh_web_config = get_config() # Build paths inside the project like this: os.path.join(BASE_DIR, ...) PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = swh_web_config["secret_key"] # SECURITY WARNING: don't run with debug turned on in production! DEBUG = swh_web_config["debug"] DEBUG_PROPAGATE_EXCEPTIONS = swh_web_config["debug"] ALLOWED_HOSTS = ["127.0.0.1", "localhost"] + swh_web_config["allowed_hosts"] # Application definition INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "rest_framework", "swh.web.common", "swh.web.api", "swh.web.auth", "swh.web.browse", + "swh.web.add_forge_now", "webpack_loader", "django_js_reverse", "corsheaders", ] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "corsheaders.middleware.CorsMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "swh.auth.django.middlewares.OIDCSessionExpiredMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "swh.web.common.middlewares.ThrottlingHeadersMiddleware", "swh.web.common.middlewares.ExceptionMiddleware", ] # Compress all assets (static ones and dynamically generated html) # served by django in a local development environment context. # In a production environment, assets compression will be directly # handled by web servers like apache or nginx. if swh_web_config["serve_assets"]: MIDDLEWARE.insert(0, "django.middleware.gzip.GZipMiddleware") ROOT_URLCONF = "swh.web.urls" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [os.path.join(PROJECT_DIR, "../templates")], "APP_DIRS": True, "OPTIONS": { "context_processors": [ "django.template.context_processors.debug", "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", "swh.web.common.utils.context_processor", ], "libraries": {"swh_templatetags": "swh.web.common.swh_templatetags",}, }, }, ] DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": swh_web_config.get("development_db", ""), } } # Password validation # https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", # noqa }, {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",}, {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",}, {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",}, ] # Internationalization # https://docs.djangoproject.com/en/1.11/topics/i18n/ LANGUAGE_CODE = "en-us" TIME_ZONE = "UTC" USE_I18N = True USE_L10N = True USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.11/howto/static-files/ STATIC_URL = "/static/" # static folder location when swh-web has been installed with pip STATIC_DIR = os.path.join(sys.prefix, "share/swh/web/static") if not os.path.exists(STATIC_DIR): # static folder location when developping swh-web STATIC_DIR = os.path.join(PROJECT_DIR, "../../../static") STATICFILES_DIRS = [STATIC_DIR] INTERNAL_IPS = ["127.0.0.1"] throttle_rates = {} http_requests = ["GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"] throttling = swh_web_config["throttling"] for limiter_scope, limiter_conf in throttling["scopes"].items(): if "default" in limiter_conf["limiter_rate"]: throttle_rates[limiter_scope] = limiter_conf["limiter_rate"]["default"] # for backward compatibility else: throttle_rates[limiter_scope] = limiter_conf["limiter_rate"] # register sub scopes specific for HTTP request types for http_request in http_requests: if http_request in limiter_conf["limiter_rate"]: throttle_rates[limiter_scope + "_" + http_request.lower()] = limiter_conf[ "limiter_rate" ][http_request] REST_FRAMEWORK: Dict[str, Any] = { "DEFAULT_RENDERER_CLASSES": ( "rest_framework.renderers.JSONRenderer", "swh.web.api.renderers.YAMLRenderer", "rest_framework.renderers.TemplateHTMLRenderer", ), "DEFAULT_THROTTLE_CLASSES": ( "swh.web.api.throttling.SwhWebRateThrottle", "swh.web.api.throttling.SwhWebUserRateThrottle", ), "DEFAULT_THROTTLE_RATES": throttle_rates, "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework.authentication.SessionAuthentication", "swh.auth.django.backends.OIDCBearerTokenAuthentication", ], "EXCEPTION_HANDLER": "swh.web.api.apiresponse.error_response_handler", } LOGGING = { "version": 1, "disable_existing_loggers": False, "filters": { "require_debug_false": {"()": "django.utils.log.RequireDebugFalse",}, "require_debug_true": {"()": "django.utils.log.RequireDebugTrue",}, }, "formatters": { "request": { "format": "[%(asctime)s] [%(levelname)s] %(request)s %(status_code)s", "datefmt": "%d/%b/%Y %H:%M:%S", }, "simple": { "format": "[%(asctime)s] [%(levelname)s] %(message)s", "datefmt": "%d/%b/%Y %H:%M:%S", }, "verbose": { "format": ( "[%(asctime)s] [%(levelname)s] %(name)s.%(funcName)s:%(lineno)s " "- %(message)s" ), "datefmt": "%d/%b/%Y %H:%M:%S", }, }, "handlers": { "console": { "level": "DEBUG", "filters": ["require_debug_true"], "class": "logging.StreamHandler", "formatter": "simple", }, "file": { "level": "WARNING", "filters": ["require_debug_false"], "class": "logging.FileHandler", "filename": os.path.join(swh_web_config["log_dir"], "swh-web.log"), "formatter": "simple", }, "file_request": { "level": "WARNING", "filters": ["require_debug_false"], "class": "logging.FileHandler", "filename": os.path.join(swh_web_config["log_dir"], "swh-web.log"), "formatter": "request", }, "console_verbose": { "level": "DEBUG", "filters": ["require_debug_true"], "class": "logging.StreamHandler", "formatter": "verbose", }, "file_verbose": { "level": "WARNING", "filters": ["require_debug_false"], "class": "logging.FileHandler", "filename": os.path.join(swh_web_config["log_dir"], "swh-web.log"), "formatter": "verbose", }, "null": {"class": "logging.NullHandler",}, }, "loggers": { "": { "handlers": ["console_verbose", "file_verbose"], "level": "DEBUG" if DEBUG else "WARNING", }, "django": { "handlers": ["console"], "level": "DEBUG" if DEBUG else "WARNING", "propagate": False, }, "django.request": { "handlers": ["file_request"], "level": "DEBUG" if DEBUG else "WARNING", "propagate": False, }, "django.db.backends": {"handlers": ["null"], "propagate": False}, "django.utils.autoreload": {"level": "INFO",}, "swh.core.statsd": {"level": "INFO",}, }, } WEBPACK_LOADER = { "DEFAULT": { "CACHE": False, "BUNDLE_DIR_NAME": "./", "STATS_FILE": os.path.join(STATIC_DIR, "webpack-stats.json"), "POLL_INTERVAL": 0.1, "TIMEOUT": None, "IGNORE": [".+\\.hot-update.js", ".+\\.map"], } } LOGIN_URL = "/admin/login/" LOGIN_REDIRECT_URL = "admin" SESSION_ENGINE = "django.contrib.sessions.backends.cache" CACHES = { "default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}, } JS_REVERSE_JS_MINIFY = False CORS_ORIGIN_ALLOW_ALL = True CORS_URLS_REGEX = r"^/(badge|api)/.*$" AUTHENTICATION_BACKENDS = [ "django.contrib.auth.backends.ModelBackend", "swh.auth.django.backends.OIDCAuthorizationCodePKCEBackend", ] SWH_AUTH_SERVER_URL = swh_web_config["keycloak"]["server_url"] SWH_AUTH_REALM_NAME = swh_web_config["keycloak"]["realm_name"] SWH_AUTH_CLIENT_ID = OIDC_SWH_WEB_CLIENT_ID SWH_AUTH_SESSION_EXPIRED_REDIRECT_VIEW = "logout"