├── src ├── api │ ├── __init__.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_info_endpoint.py │ │ ├── test_reports_endpoint.py │ │ ├── conftest.py │ │ └── test_articles_endpoint.py │ ├── utils │ │ ├── __init__.py │ │ ├── types │ │ │ ├── __init__.py │ │ │ ├── launch_response.py │ │ │ └── event_response.py │ │ └── pagination.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0002_alter_article_options_alter_blog_options.py │ │ ├── 0007_add_audited_field.py │ │ ├── 0003_article_is_deleted_blog_is_deleted_report_is_deleted.py │ │ ├── 0004_alter_article_updated_at_alter_blog_updated_at_and_more.py │ │ ├── 0006_socials_bluesky_socials_linkedin_socials_mastodon_and_more.py │ │ ├── 0005_add_author_and_socials.py │ │ └── 0001_initial.py │ ├── models │ │ ├── abc │ │ │ ├── __init__.py │ │ │ └── news_item.py │ │ ├── blog.py │ │ ├── article.py │ │ ├── provider.py │ │ ├── author.py │ │ ├── news_site.py │ │ ├── __init__.py │ │ ├── event.py │ │ ├── socials.py │ │ ├── launch.py │ │ └── report.py │ ├── static │ │ └── api │ │ │ ├── images │ │ │ └── favicon.ico │ │ │ └── fonts │ │ │ ├── glyphicons-halflings-regular.eot │ │ │ ├── glyphicons-halflings-regular.ttf │ │ │ ├── glyphicons-halflings-regular.woff │ │ │ └── glyphicons-halflings-regular.woff2 │ ├── apps.py │ ├── serializers │ │ ├── utils.py │ │ ├── news_site_serializer.py │ │ ├── socials_serializer.py │ │ ├── info_serializer.py │ │ ├── event_serializer.py │ │ ├── launch_serializer.py │ │ ├── author_serializer.py │ │ ├── report_serializer.py │ │ ├── __init__.py │ │ ├── blog_serializer.py │ │ └── article_serializer.py │ ├── views │ │ ├── __init__.py │ │ ├── info.py │ │ ├── blogs.py │ │ ├── articles.py │ │ ├── reports.py │ │ └── filters.py │ ├── templates │ │ ├── admin │ │ │ └── base_site.html │ │ ├── rest_framework │ │ │ └── api.html │ │ └── api │ │ │ └── swagger-ui.html │ ├── urls.py │ ├── schema.py │ └── admin.py ├── importer │ ├── __init__.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ ├── import.py │ │ │ ├── _launch_events.py │ │ │ └── _news_items.py │ ├── tests.py │ ├── apps.py │ └── serializers.py ├── snapy │ ├── __init__.py │ ├── schema.py │ ├── asgi.py │ ├── wsgi.py │ ├── urls.py │ └── settings.py ├── static │ ├── jet │ │ └── css │ │ │ └── themes │ │ │ └── dark │ │ │ ├── base.scss │ │ │ ├── select2.theme.scss │ │ │ ├── jquery-ui.theme.scss │ │ │ ├── _variables.scss │ │ │ └── jquery-ui.theme.css │ └── css │ │ └── custom_admin.css ├── manage.py └── gunicorn.config.py ├── .python-version ├── .github ├── profile │ └── assets │ │ ├── snapi_poster.png │ │ ├── badge_snapi_website.svg │ │ └── badge_snapi_doc.svg ├── workflows │ ├── cleanup.yml │ ├── semantic-release.yaml │ ├── staging.yml │ ├── production.yml │ └── tests.yml ├── actions │ ├── setup-python │ │ └── action.yml │ └── build-image │ │ └── action.yml ├── FUNDING.yml └── dependabot.yml ├── SECURITY.md ├── .vscode └── launch.json ├── .env.example ├── .pre-commit-config.yaml ├── Dockerfile ├── README.md ├── pyproject.toml └── .gitignore /src/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /src/api/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/api/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/importer/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/api/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/api/utils/types/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/importer/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/importer/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/snapy/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "4.27.0" 2 | -------------------------------------------------------------------------------- /src/static/jet/css/themes/dark/base.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | @import "../../base"; 3 | -------------------------------------------------------------------------------- /src/importer/tests.py: -------------------------------------------------------------------------------- 1 | # from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /src/api/models/abc/__init__.py: -------------------------------------------------------------------------------- 1 | from api.models.abc.news_item import NewsItem 2 | 3 | __all__ = ["NewsItem"] 4 | -------------------------------------------------------------------------------- /src/api/models/blog.py: -------------------------------------------------------------------------------- 1 | from api.models.abc import NewsItem 2 | 3 | 4 | class Blog(NewsItem): 5 | pass 6 | -------------------------------------------------------------------------------- /src/static/jet/css/themes/dark/select2.theme.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | @import "../../select2/layout"; 3 | -------------------------------------------------------------------------------- /src/api/models/article.py: -------------------------------------------------------------------------------- 1 | from api.models.abc import NewsItem 2 | 3 | 4 | class Article(NewsItem): 5 | pass 6 | -------------------------------------------------------------------------------- /src/static/jet/css/themes/dark/jquery-ui.theme.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | @import "../../jquery-ui/jquery-ui.theme"; 3 | -------------------------------------------------------------------------------- /src/static/jet/css/themes/dark/_variables.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * These theme uses default variables at ../_variables.scss 3 | */ 4 | -------------------------------------------------------------------------------- /src/api/static/api/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheSpaceDevs/spaceflightnewsapi/HEAD/src/api/static/api/images/favicon.ico -------------------------------------------------------------------------------- /.github/profile/assets/snapi_poster.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheSpaceDevs/spaceflightnewsapi/HEAD/.github/profile/assets/snapi_poster.png -------------------------------------------------------------------------------- /src/api/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ApiConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "api" 7 | -------------------------------------------------------------------------------- /src/api/static/api/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheSpaceDevs/spaceflightnewsapi/HEAD/src/api/static/api/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /src/api/static/api/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheSpaceDevs/spaceflightnewsapi/HEAD/src/api/static/api/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /src/api/static/api/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheSpaceDevs/spaceflightnewsapi/HEAD/src/api/static/api/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /src/api/serializers/utils.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict 2 | 3 | 4 | class ClientOptions(TypedDict): 5 | base_url: str 6 | headers: dict[str, str] 7 | timeout: float 8 | -------------------------------------------------------------------------------- /src/api/static/api/fonts/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheSpaceDevs/spaceflightnewsapi/HEAD/src/api/static/api/fonts/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /src/api/utils/pagination.py: -------------------------------------------------------------------------------- 1 | from rest_framework.pagination import LimitOffsetPagination 2 | 3 | 4 | class CustomLimitOffsetPagination(LimitOffsetPagination): 5 | max_limit = 500 6 | -------------------------------------------------------------------------------- /src/importer/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ImporterConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "importer" 7 | -------------------------------------------------------------------------------- /src/api/models/provider.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Provider(models.Model): 5 | name = models.CharField(max_length=250) 6 | 7 | def __str__(self) -> str: 8 | return self.name 9 | -------------------------------------------------------------------------------- /src/snapy/schema.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | 3 | from graphene import ObjectType, Schema 4 | 5 | from api.schema import Query as ApiQuery 6 | 7 | 8 | class Query(ApiQuery, ObjectType): 9 | pass 10 | 11 | 12 | schema = Schema(query=Query) 13 | -------------------------------------------------------------------------------- /src/api/views/__init__.py: -------------------------------------------------------------------------------- 1 | from api.views.articles import ArticleViewSet 2 | from api.views.blogs import BlogViewSet 3 | from api.views.info import InfoView 4 | from api.views.reports import ReportViewSet 5 | 6 | __all__ = ["ArticleViewSet", "BlogViewSet", "InfoView", "ReportViewSet"] 7 | -------------------------------------------------------------------------------- /src/api/serializers/news_site_serializer.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from api.models import NewsSite 4 | 5 | 6 | class NewsSiteSerializer(serializers.ModelSerializer[NewsSite]): 7 | class Meta: 8 | model = NewsSite 9 | fields = ["id", "name"] 10 | -------------------------------------------------------------------------------- /src/api/serializers/socials_serializer.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from api.models.socials import Socials 4 | 5 | 6 | class SocialsSerializer(serializers.ModelSerializer[Socials]): 7 | class Meta: 8 | model = Socials 9 | fields = ["x", "youtube", "instagram", "linkedin", "mastodon", "bluesky"] 10 | -------------------------------------------------------------------------------- /src/api/serializers/info_serializer.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | 4 | # The type is set to None since it's not bound to a model 5 | # and isn't used to deserialize data. 6 | class InfoSerializer(serializers.Serializer[None]): 7 | version = serializers.CharField() 8 | news_sites = serializers.ListField(child=serializers.CharField()) 9 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | >= 4 | :white_check_mark: | 8 | | <= 3 | :x: | 9 | 10 | ## Reporting a Vulnerability 11 | 12 | To report a vulnarability, please reach out to us on [Discord](https://discord.com/invite/p7ntkNA). 13 | -------------------------------------------------------------------------------- /src/api/models/author.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from api.models.socials import Socials 4 | 5 | 6 | class Author(models.Model): 7 | name = models.CharField(max_length=250) 8 | socials = models.ForeignKey(Socials, on_delete=models.CASCADE, null=True, blank=True) 9 | 10 | def __str__(self) -> str: 11 | return self.name 12 | -------------------------------------------------------------------------------- /src/api/templates/admin/base_site.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load static %} 3 | {% block extrahead %} 4 | 5 | {% endblock %} 6 | {% block html %}{{ block.super }} 7 | 8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /src/static/css/custom_admin.css: -------------------------------------------------------------------------------- 1 | .hover-image-list { 2 | transition: transform 0.7s ease; 3 | } 4 | 5 | .hover-image-list:hover { 6 | transform: scale(5); /* Increases size by 5 times */ 7 | } 8 | 9 | .hover-image-detail { 10 | transition: width 0.7s ease; 11 | } 12 | 13 | .hover-image-detail:hover { 14 | width: 60%; /* Increases width by 60% */ 15 | } 16 | -------------------------------------------------------------------------------- /src/api/models/news_site.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from django.db import models 4 | 5 | 6 | class NewsSite(models.Model): 7 | name = models.CharField(max_length=250) 8 | 9 | def __str__(self) -> str: 10 | return self.name 11 | 12 | @staticmethod 13 | def autocomplete_search_fields() -> tuple[Literal["name"],]: 14 | return ("name",) 15 | -------------------------------------------------------------------------------- /src/api/serializers/event_serializer.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from api.models import Event, Provider 4 | 5 | 6 | class EventSerializer(serializers.ModelSerializer[Event]): 7 | provider: "serializers.StringRelatedField[Provider]" = serializers.StringRelatedField() 8 | 9 | class Meta: 10 | model = Event 11 | fields = ["event_id", "provider"] 12 | -------------------------------------------------------------------------------- /src/api/serializers/launch_serializer.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from api.models import Launch, Provider 4 | 5 | 6 | class LaunchSerializer(serializers.ModelSerializer[Launch]): 7 | provider: "serializers.StringRelatedField[Provider]" = serializers.StringRelatedField() 8 | 9 | class Meta: 10 | model = Launch 11 | fields = ["launch_id", "provider"] 12 | -------------------------------------------------------------------------------- /.github/workflows/cleanup.yml: -------------------------------------------------------------------------------- 1 | name: Run cleanup tasks 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '0 0 * * *' 7 | 8 | jobs: 9 | delete_runs: 10 | permissions: 11 | actions: write 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Delete workflow runs 15 | uses: dmvict/clean-workflow-runs@v1.2.2 16 | with: 17 | save_min_runs_number: 0 18 | -------------------------------------------------------------------------------- /src/api/serializers/author_serializer.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from api.models.author import Author 4 | from api.serializers.socials_serializer import SocialsSerializer 5 | 6 | 7 | class AuthorSerializer(serializers.ModelSerializer[Author]): 8 | socials = SocialsSerializer(required=False) 9 | 10 | class Meta: 11 | model = Author 12 | fields = ["name", "socials"] 13 | -------------------------------------------------------------------------------- /src/snapy/asgi.py: -------------------------------------------------------------------------------- 1 | """ASGI config for snapy project. 2 | 3 | It exposes the ASGI callable as a module-level variable named ``application``. 4 | 5 | For more information on this file, see 6 | https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ 7 | """ 8 | 9 | import os 10 | 11 | from django.core.asgi import get_asgi_application 12 | 13 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "snapy.settings") 14 | 15 | application = get_asgi_application() 16 | -------------------------------------------------------------------------------- /src/snapy/wsgi.py: -------------------------------------------------------------------------------- 1 | """WSGI config for snapy project. 2 | 3 | It exposes the WSGI callable as a module-level variable named ``application``. 4 | 5 | For more information on this file, see 6 | https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ 7 | """ 8 | 9 | import os 10 | 11 | from django.core.wsgi import get_wsgi_application 12 | 13 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "snapy.settings") 14 | 15 | application = get_wsgi_application() 16 | -------------------------------------------------------------------------------- /src/api/models/__init__.py: -------------------------------------------------------------------------------- 1 | from api.models.article import Article 2 | from api.models.author import Author 3 | from api.models.blog import Blog 4 | from api.models.event import Event 5 | from api.models.launch import Launch 6 | from api.models.news_site import NewsSite 7 | from api.models.provider import Provider 8 | from api.models.report import Report 9 | from api.models.socials import Socials 10 | 11 | __all__ = ["Article", "Author", "Blog", "Event", "Launch", "NewsSite", "Socials", "Provider", "Report"] 12 | -------------------------------------------------------------------------------- /src/api/models/event.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from django.db import models 4 | 5 | 6 | class Event(models.Model): 7 | event_id = models.IntegerField() 8 | name = models.CharField(max_length=250) 9 | provider = models.ForeignKey("Provider", on_delete=models.CASCADE) 10 | 11 | def __str__(self) -> str: 12 | return self.name 13 | 14 | @staticmethod 15 | def autocomplete_search_fields() -> tuple[Literal["name"], Literal["event_id"]]: 16 | return ("name", "event_id") 17 | -------------------------------------------------------------------------------- /src/api/templates/rest_framework/api.html: -------------------------------------------------------------------------------- 1 | {% extends "rest_framework/base.html" %} 2 | {% load static %} 3 | 4 | {% block title %} 5 | 🚀Spaceflight News API | Browseable API 6 | {% endblock %} 7 | 8 | {% block bootstrap_theme %} 9 | 10 | {% endblock %} 11 | 12 | {% block branding %} 13 | Spaceflight News API 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /src/api/models/socials.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Socials(models.Model): 5 | name = models.CharField() 6 | x = models.URLField(blank=True) 7 | youtube = models.URLField(blank=True) 8 | instagram = models.URLField(blank=True) 9 | bluesky = models.URLField(blank=True) 10 | linkedin = models.URLField(blank=True) 11 | mastodon = models.URLField(blank=True) 12 | 13 | class Meta: 14 | verbose_name_plural = "Socials" 15 | 16 | def __str__(self) -> str: 17 | return self.name 18 | -------------------------------------------------------------------------------- /src/api/migrations/0002_alter_article_options_alter_blog_options.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.1 on 2023-05-20 11:40 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterModelOptions( 13 | name="article", 14 | options={}, 15 | ), 16 | migrations.AlterModelOptions( 17 | name="blog", 18 | options={}, 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /src/api/models/launch.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from django.db import models 4 | 5 | 6 | class Launch(models.Model): 7 | launch_id = models.UUIDField(unique=True) 8 | name = models.CharField(max_length=250) 9 | provider = models.ForeignKey("Provider", on_delete=models.CASCADE) 10 | 11 | class Meta: 12 | verbose_name_plural = "Launches" 13 | 14 | def __str__(self) -> str: 15 | return self.name 16 | 17 | @staticmethod 18 | def autocomplete_search_fields() -> tuple[Literal["name"], Literal["launch_id"]]: 19 | return ("name", "launch_id") 20 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | 8 | { 9 | "name": "Django", 10 | "type": "debugpy", 11 | "request": "launch", 12 | "args": [ 13 | "runserver" 14 | ], 15 | "django": true, 16 | "autoStartBrowser": false, 17 | "program": "${workspaceFolder}/src/manage.py" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.github/actions/setup-python/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Setup Python & Poetry' 2 | description: 'Setup Python and Poetry for the project' 3 | inputs: 4 | python-version: 5 | description: 'Version that will be used to tag the image. Should be the version of the API.' 6 | default: '3.12' 7 | uv-version: 8 | description: 'UV version to use.' 9 | default: '0.5.2' 10 | runs: 11 | using: "composite" 12 | steps: 13 | - uses: actions/setup-python@v5 14 | with: 15 | python-version: ${{ inputs.python-version }} 16 | 17 | - name: Setup | Install uv 18 | uses: astral-sh/setup-uv@v6 19 | with: 20 | version: ${{ inputs.uv-version }} 21 | -------------------------------------------------------------------------------- /src/api/migrations/0007_add_audited_field.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.4 on 2025-08-14 14:34 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0006_socials_bluesky_socials_linkedin_socials_mastodon_and_more"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="article", 14 | name="audited", 15 | field=models.BooleanField(default=False), 16 | ), 17 | migrations.AddField( 18 | model_name="blog", 19 | name="audited", 20 | field=models.BooleanField(default=False), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /src/api/serializers/report_serializer.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from api.models import NewsSite, Report 4 | from api.serializers.author_serializer import AuthorSerializer 5 | 6 | 7 | class ReportSerializer(serializers.ModelSerializer[Report]): 8 | news_site: "serializers.StringRelatedField[NewsSite]" = serializers.StringRelatedField() 9 | authors = AuthorSerializer(many=True) 10 | 11 | class Meta: 12 | model = Report 13 | fields = [ 14 | "id", 15 | "title", 16 | "authors", 17 | "url", 18 | "image_url", 19 | "news_site", 20 | "summary", 21 | "published_at", 22 | "updated_at", 23 | ] 24 | -------------------------------------------------------------------------------- /src/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | 4 | import os 5 | import sys 6 | 7 | 8 | def main() -> None: 9 | """Run administrative tasks.""" 10 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "snapy.settings") 11 | try: 12 | from django.core.management import execute_from_command_line 13 | except ImportError as exc: 14 | raise ImportError( 15 | "Couldn't import Django. Are you sure it's installed and " 16 | "available on your PYTHONPATH environment variable? Did you " 17 | "forget to activate a virtual environment?" 18 | ) from exc 19 | execute_from_command_line(sys.argv) 20 | 21 | 22 | if __name__ == "__main__": 23 | main() 24 | -------------------------------------------------------------------------------- /src/api/views/info.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from rest_framework.request import Request 3 | from rest_framework.response import Response 4 | from rest_framework.views import APIView 5 | 6 | from api.models import NewsSite 7 | from api.serializers import InfoSerializer 8 | 9 | 10 | class InfoView(APIView): 11 | serializer_class = InfoSerializer 12 | authentication_classes = [] 13 | 14 | def _get_news_sites(self) -> list[str]: 15 | news_sites = NewsSite.objects.all().order_by("name") 16 | sites = [site.name for site in news_sites] 17 | 18 | return sites 19 | 20 | def get(self, _request: Request) -> Response: 21 | sites = self._get_news_sites() 22 | return Response({"version": settings.VERSION, "news_sites": sites}) 23 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DEBUG=true 2 | 3 | SECRET_KEY='django-insecure-2kkmxbid^6w20^-5c#g+*@b%sl@0e1j85%@)yh(&t_z@hkqxvu' 4 | 5 | CSRF_TRUSTED_ORIGIN=http://localhost 6 | 7 | LL_TOKEN=ae715685a458b03b5e1118bc99ee2d5920187fff 8 | LL_URL=https://ll.thespacedevs.com/2.3.0 9 | 10 | DATABASE_URL=postgres://user:password@$localhost:5432/snapi_dev 11 | 12 | AWS_ACCESS_KEY_ID=DO00P9FFXXY8WWA98YDN 13 | AWS_SECRET_ACCESS_KEY=JeRC1BBg2fZKVhcJO6LZs54fxEqMmrxGuBR09UjpfhA 14 | AWS_STORAGE_BUCKET_NAME=snapi-dev 15 | 16 | UV_INDEX_TSD_USERNAME=public 17 | UV_INDEX_TSD_PASSWORD=public 18 | 19 | REDIS_URL=redis://localhost:6379 20 | 21 | ENABLE_THROTTLE=True 22 | ENABLE_CACHE=True 23 | CACHALOT_ENABLED=True 24 | NUM_PROXIES=0 25 | 26 | OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 27 | OTEL_SERVICE_NAME=spaceflightnewsapi 28 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: TheSpaceDevs 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: derkweijers 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /src/api/views/blogs.py: -------------------------------------------------------------------------------- 1 | from django_filters import rest_framework 2 | from rest_framework import viewsets 3 | 4 | from api.models import Blog 5 | from api.serializers import BlogSerializer 6 | from api.views.filters import DocsFilter, SearchFilter 7 | 8 | 9 | class BlogViewSet(viewsets.ReadOnlyModelViewSet): # type: ignore 10 | queryset = ( 11 | Blog.objects.exclude(is_deleted=True) 12 | .prefetch_related("launches", "events", "authors") 13 | .select_related("news_site") 14 | .order_by("-published_at") 15 | ) 16 | serializer_class = BlogSerializer 17 | authentication_classes = [] 18 | filter_backends = [ 19 | rest_framework.DjangoFilterBackend, 20 | SearchFilter, 21 | ] 22 | filterset_class = DocsFilter 23 | search_fields = ["title", "summary", "news_site__name"] 24 | -------------------------------------------------------------------------------- /src/api/views/articles.py: -------------------------------------------------------------------------------- 1 | from django_filters import rest_framework 2 | from rest_framework import viewsets 3 | 4 | from api.models import Article 5 | from api.serializers import ArticleSerializer 6 | from api.views.filters import DocsFilter, SearchFilter 7 | 8 | 9 | class ArticleViewSet(viewsets.ReadOnlyModelViewSet): # type: ignore 10 | queryset = ( 11 | Article.objects.exclude(is_deleted=True) 12 | .prefetch_related("launches", "events", "authors") 13 | .select_related("news_site") 14 | .order_by("-published_at") 15 | ) 16 | serializer_class = ArticleSerializer 17 | authentication_classes = [] 18 | filter_backends = [ 19 | rest_framework.DjangoFilterBackend, 20 | SearchFilter, 21 | ] 22 | filterset_class = DocsFilter 23 | search_fields = ["title", "summary", "news_site__name"] 24 | -------------------------------------------------------------------------------- /src/api/views/reports.py: -------------------------------------------------------------------------------- 1 | from django_filters import rest_framework 2 | from rest_framework import viewsets 3 | 4 | from api.models.report import Report 5 | from api.serializers.report_serializer import ReportSerializer 6 | from api.views.filters import BaseFilter, SearchFilter 7 | 8 | 9 | class ReportViewSet(viewsets.ReadOnlyModelViewSet): # type: ignore 10 | queryset = ( 11 | Report.objects.exclude(is_deleted=True) 12 | .select_related("news_site") 13 | .prefetch_related("authors") 14 | .order_by("-published_at") 15 | ) 16 | serializer_class = ReportSerializer 17 | authentication_classes = [] 18 | filterset_class = BaseFilter 19 | filter_backends = [ 20 | rest_framework.DjangoFilterBackend, 21 | SearchFilter, 22 | ] 23 | search_fields = ["title", "summary", "news_site__name"] 24 | -------------------------------------------------------------------------------- /src/api/tests/test_info_endpoint.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | 3 | import pytest 4 | from django.test.client import Client 5 | 6 | from api.models import NewsSite 7 | 8 | 9 | @pytest.mark.django_db 10 | @pytest.mark.skip(reason="Fails during testing, works in production.") 11 | class TestInfoEndpoint: 12 | def test_version(self, client: Client) -> None: 13 | response = client.get("/v4/info/") 14 | assert response.status_code == 200 15 | 16 | data = response.json() 17 | 18 | assert data["version"] == importlib.metadata.version("snapy") 19 | 20 | def test_news_sites(self, client: Client, news_sites: list[NewsSite]) -> None: 21 | response = client.get("/v4/info/") 22 | assert response.status_code == 200 23 | 24 | data = response.json() 25 | 26 | assert all(site.name in data["news_sites"] for site in news_sites) 27 | -------------------------------------------------------------------------------- /src/api/serializers/__init__.py: -------------------------------------------------------------------------------- 1 | from api.serializers.article_serializer import ArticleSerializer 2 | from api.serializers.author_serializer import AuthorSerializer 3 | from api.serializers.blog_serializer import BlogSerializer 4 | from api.serializers.event_serializer import EventSerializer 5 | from api.serializers.info_serializer import InfoSerializer 6 | from api.serializers.launch_serializer import LaunchSerializer 7 | from api.serializers.news_site_serializer import NewsSiteSerializer 8 | from api.serializers.report_serializer import ReportSerializer 9 | from api.serializers.socials_serializer import SocialsSerializer 10 | 11 | __all__ = [ 12 | "ArticleSerializer", 13 | "AuthorSerializer", 14 | "BlogSerializer", 15 | "EventSerializer", 16 | "LaunchSerializer", 17 | "SocialsSerializer", 18 | "NewsSiteSerializer", 19 | "ReportSerializer", 20 | "InfoSerializer", 21 | ] 22 | -------------------------------------------------------------------------------- /src/api/migrations/0003_article_is_deleted_blog_is_deleted_report_is_deleted.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.7 on 2023-11-13 11:31 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0002_alter_article_options_alter_blog_options"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="article", 14 | name="is_deleted", 15 | field=models.BooleanField(default=False), 16 | ), 17 | migrations.AddField( 18 | model_name="blog", 19 | name="is_deleted", 20 | field=models.BooleanField(default=False), 21 | ), 22 | migrations.AddField( 23 | model_name="report", 24 | name="is_deleted", 25 | field=models.BooleanField(default=False), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /src/api/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include, path 2 | from django.views.generic import RedirectView 3 | from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView 4 | from rest_framework import routers 5 | 6 | from api import views 7 | 8 | api_router = routers.SimpleRouter() 9 | api_router.register(r"articles", views.ArticleViewSet, basename="articles") 10 | api_router.register(r"blogs", views.BlogViewSet, basename="blogs") 11 | api_router.register(r"reports", views.ReportViewSet, basename="reports") 12 | 13 | urlpatterns = [ 14 | path("", RedirectView.as_view(url="docs/")), 15 | path("", include(api_router.urls)), 16 | path("info/", views.InfoView.as_view()), 17 | path("schema/", SpectacularAPIView.as_view(api_version="v4"), name="schema"), 18 | path( 19 | "docs/", 20 | SpectacularSwaggerView.as_view(url_name="schema"), 21 | name="swagger-ui", 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /src/importer/management/commands/import.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand, CommandParser 2 | 3 | from importer.management.commands._launch_events import fetch_events, fetch_launches 4 | from importer.management.commands._news_items import fetch_news 5 | 6 | 7 | class Command(BaseCommand): 8 | def add_arguments(self, parser: CommandParser) -> None: 9 | parser.add_argument("--launches", action="store_true", help="Fetch launches") 10 | parser.add_argument("--events", action="store_true", help="Fetch events") 11 | parser.add_argument("--news", action="store_true", help="Fetch news") 12 | 13 | def handle(self, *args: tuple[str], **options: dict[str, str | bool]) -> None: 14 | if options["news"]: 15 | fetch_news() 16 | 17 | if options["launches"]: 18 | fetch_launches() 19 | 20 | if options["events"]: 21 | fetch_events() 22 | -------------------------------------------------------------------------------- /src/api/migrations/0004_alter_article_updated_at_alter_blog_updated_at_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.9 on 2024-01-25 19:47 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0003_article_is_deleted_blog_is_deleted_report_is_deleted"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="article", 14 | name="updated_at", 15 | field=models.DateTimeField(auto_now=True), 16 | ), 17 | migrations.AlterField( 18 | model_name="blog", 19 | name="updated_at", 20 | field=models.DateTimeField(auto_now=True), 21 | ), 22 | migrations.AlterField( 23 | model_name="report", 24 | name="updated_at", 25 | field=models.DateTimeField(auto_now=True), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /src/api/templates/api/swagger-ui.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Swagger 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/api/models/report.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from django.db import models 4 | 5 | from api.models.author import Author 6 | 7 | 8 | class Report(models.Model): 9 | title = models.CharField(max_length=250) 10 | url = models.URLField() 11 | image_url = models.URLField() 12 | news_site = models.ForeignKey("NewsSite", on_delete=models.CASCADE) 13 | summary = models.TextField(blank=True) 14 | published_at = models.DateTimeField() 15 | updated_at = models.DateTimeField(auto_now=True) 16 | is_deleted = models.BooleanField(default=False) 17 | authors = models.ManyToManyField(Author, blank=True) 18 | 19 | class Meta: 20 | ordering = ["-published_at"] 21 | 22 | def __str__(self) -> str: 23 | return self.title 24 | 25 | def delete(self, using: Any = ..., keep_parents: bool = ...) -> None: # type: ignore 26 | """Mark the item as delete instead of actually deleting it.""" 27 | self.is_deleted = True 28 | return self.save() 29 | -------------------------------------------------------------------------------- /src/api/serializers/blog_serializer.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from api.models import Blog, NewsSite 4 | from api.serializers.author_serializer import AuthorSerializer 5 | from api.serializers.event_serializer import EventSerializer 6 | from api.serializers.launch_serializer import LaunchSerializer 7 | 8 | 9 | class BlogSerializer(serializers.ModelSerializer[Blog]): 10 | news_site: "serializers.StringRelatedField[NewsSite]" = serializers.StringRelatedField() 11 | launches = LaunchSerializer(many=True) 12 | events = EventSerializer(many=True) 13 | authors = AuthorSerializer(many=True) 14 | 15 | class Meta: 16 | model = Blog 17 | fields = [ 18 | "id", 19 | "title", 20 | "authors", 21 | "url", 22 | "image_url", 23 | "news_site", 24 | "summary", 25 | "published_at", 26 | "updated_at", 27 | "featured", 28 | "launches", 29 | "events", 30 | ] 31 | -------------------------------------------------------------------------------- /src/api/serializers/article_serializer.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from api.models import Article, NewsSite 4 | from api.serializers.author_serializer import AuthorSerializer 5 | from api.serializers.event_serializer import EventSerializer 6 | from api.serializers.launch_serializer import LaunchSerializer 7 | 8 | 9 | class ArticleSerializer(serializers.ModelSerializer[Article]): 10 | news_site: "serializers.StringRelatedField[NewsSite]" = serializers.StringRelatedField() 11 | launches = LaunchSerializer(many=True) 12 | events = EventSerializer(many=True) 13 | authors = AuthorSerializer(many=True) 14 | 15 | class Meta: 16 | model = Article 17 | fields = [ 18 | "id", 19 | "title", 20 | "authors", 21 | "url", 22 | "image_url", 23 | "news_site", 24 | "summary", 25 | "published_at", 26 | "updated_at", 27 | "featured", 28 | "launches", 29 | "events", 30 | ] 31 | -------------------------------------------------------------------------------- /src/api/migrations/0006_socials_bluesky_socials_linkedin_socials_mastodon_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.16 on 2025-01-15 19:36 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("api", "0005_add_author_and_socials"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="socials", 14 | name="bluesky", 15 | field=models.URLField(blank=True), 16 | ), 17 | migrations.AddField( 18 | model_name="socials", 19 | name="linkedin", 20 | field=models.URLField(blank=True), 21 | ), 22 | migrations.AddField( 23 | model_name="socials", 24 | name="mastodon", 25 | field=models.URLField(blank=True), 26 | ), 27 | migrations.AddField( 28 | model_name="socials", 29 | name="name", 30 | field=models.CharField(), 31 | preserve_default=False, 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | registries: 8 | tsd: 9 | type: python-index 10 | url: https://pypi.thespacedevs.com/simple/ 11 | username: ${{secrets.UV_INDEX_TSD_USERNAME}} 12 | password: ${{secrets.UV_INDEX_TSD_PASSWORD}} 13 | updates: 14 | - package-ecosystem: "uv" # See documentation for possible values 15 | directory: "/" # Location of package manifests 16 | registries: "*" 17 | assignees: 18 | - "derkweijers" 19 | schedule: 20 | interval: "weekly" 21 | groups: 22 | package-updates: 23 | applies-to: version-updates 24 | patterns: ["*"] 25 | 26 | - package-ecosystem: "github-actions" 27 | directory: "/" 28 | assignees: 29 | - "derkweijers" 30 | schedule: 31 | interval: "weekly" 32 | -------------------------------------------------------------------------------- /src/gunicorn.config.py: -------------------------------------------------------------------------------- 1 | from environs import Env 2 | from opentelemetry import trace 3 | from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( 4 | OTLPSpanExporter, 5 | ) 6 | from opentelemetry.sdk.resources import Resource 7 | from opentelemetry.sdk.trace import TracerProvider 8 | from opentelemetry.sdk.trace.export import BatchSpanProcessor 9 | 10 | env = Env() 11 | env.read_env() 12 | 13 | bind = ":8000" 14 | 15 | workers = 4 16 | worker_class = "sync" 17 | worker_connections = 1000 18 | timeout = 30 19 | keepalive = 2 20 | 21 | errorlog = "-" 22 | loglevel = "info" 23 | accesslog = "-" 24 | 25 | 26 | def post_fork(server, worker): # type: ignore 27 | """Post-fork hook to initialize OpenTelemetry tracing in each worker.""" 28 | server.log.info("Worker spawned (pid: %s)", worker.pid) 29 | 30 | resource = Resource.create(attributes={"service.name": env.str("OTEL_SERVICE_NAME")}) 31 | 32 | trace.set_tracer_provider(TracerProvider(resource=resource)) 33 | span_processor = BatchSpanProcessor(OTLPSpanExporter(endpoint=env.str("OTEL_EXPORTER_OTLP_ENDPOINT"))) 34 | trace.get_tracer_provider().add_span_processor(span_processor) # type: ignore 35 | -------------------------------------------------------------------------------- /.github/workflows/semantic-release.yaml: -------------------------------------------------------------------------------- 1 | name: Release with Python Semantic Release 2 | 3 | on: 4 | workflow_dispatch: {} 5 | 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | concurrency: release 11 | 12 | env: 13 | UV_INDEX_TSD_USERNAME: ${{ secrets.UV_INDEX_TSD_USERNAME }} 14 | UV_INDEX_TSD_PASSWORD: ${{ secrets.UV_INDEX_TSD_PASSWORD }} 15 | 16 | steps: 17 | - name: Generate a token 18 | id: generate_token 19 | uses: actions/create-github-app-token@v2 20 | with: 21 | app-id: ${{ secrets.SNAPI_APP_ID }} 22 | private-key: ${{ secrets.SNAPI_PRIVATE_KEY }} 23 | 24 | - name: Setup | Install uv 25 | uses: astral-sh/setup-uv@v7 26 | 27 | - name: Setup | Checkout Repository 28 | uses: actions/checkout@v6 29 | with: 30 | token: ${{ steps.generate_token.outputs.token }} 31 | fetch-depth: 0 32 | 33 | - name: Setup | Install the dependecies and project 34 | run: | 35 | uv sync 36 | 37 | - name: Release | Run semantic-release 38 | env: 39 | GH_TOKEN: ${{ steps.generate_token.outputs.token }} 40 | run: | 41 | uv run semantic-release version 42 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | default_install_hook_types: [pre-commit, pre-push, commit-msg] 4 | 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v6.0.0 8 | hooks: 9 | - id: trailing-whitespace 10 | - id: end-of-file-fixer 11 | - id: check-yaml 12 | - id: check-added-large-files 13 | 14 | - repo: https://github.com/PyCQA/bandit 15 | rev: 1.8.6 16 | hooks: 17 | - id: bandit 18 | args: ["-c", "pyproject.toml"] 19 | additional_dependencies: ["bandit[toml]"] 20 | 21 | - repo: https://github.com/asottile/pyupgrade 22 | rev: v3.21.1 23 | hooks: 24 | - id: pyupgrade 25 | args: ["--py313-plus"] 26 | 27 | - repo: https://github.com/astral-sh/ruff-pre-commit 28 | # Ruff version. 29 | rev: v0.14.4 30 | hooks: 31 | # Run the linter. 32 | - id: ruff-check 33 | # Run the formatter. 34 | - id: ruff-format 35 | 36 | - repo: https://github.com/commitizen-tools/commitizen 37 | hooks: 38 | - id: commitizen 39 | - id: commitizen-branch 40 | stages: 41 | - pre-push 42 | rev: v4.9.1 43 | -------------------------------------------------------------------------------- /src/api/models/abc/news_item.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from django.db import models 4 | 5 | from api.models.author import Author 6 | from api.models.event import Event 7 | from api.models.launch import Launch 8 | from api.models.news_site import NewsSite 9 | 10 | 11 | class NewsItem(models.Model): 12 | title = models.CharField(max_length=250) 13 | url = models.URLField(unique=True) 14 | image_url = models.URLField(max_length=500) 15 | news_site = models.ForeignKey(NewsSite, on_delete=models.CASCADE) 16 | summary = models.TextField() 17 | published_at = models.DateTimeField() 18 | updated_at = models.DateTimeField(auto_now=True) 19 | featured = models.BooleanField(default=False) 20 | launches = models.ManyToManyField(Launch, blank=True) 21 | events = models.ManyToManyField(Event, blank=True) 22 | is_deleted = models.BooleanField(default=False) 23 | authors = models.ManyToManyField(Author, blank=True) 24 | audited = models.BooleanField(default=False) 25 | 26 | class Meta: 27 | abstract = True 28 | 29 | def __str__(self) -> str: 30 | return self.title 31 | 32 | def delete(self, using: Any = ..., keep_parents: bool = ...) -> None: # type: ignore 33 | """Mark the item as delete instead of actually deleting it.""" 34 | self.is_deleted = True 35 | self.save() 36 | -------------------------------------------------------------------------------- /src/importer/serializers.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict 2 | 3 | from rest_framework import serializers 4 | 5 | from api.models import Event, Launch 6 | 7 | 8 | class ValidatedLaunchDataDict(TypedDict): 9 | id: str 10 | name: str 11 | 12 | 13 | class LaunchLibraryLaunchSerializer(serializers.Serializer[Launch]): 14 | id = serializers.UUIDField() 15 | name = serializers.CharField() 16 | 17 | def create(self, validated_data: ValidatedLaunchDataDict) -> Launch: 18 | launch, _ = Launch.objects.update_or_create( 19 | launch_id=validated_data["id"], 20 | defaults={ 21 | "name": validated_data["name"], 22 | "provider": self.context["provider"], 23 | }, 24 | ) 25 | 26 | return launch 27 | 28 | 29 | class ValidatedEventDataDict(TypedDict): 30 | id: int 31 | name: str 32 | 33 | 34 | class LaunchLibraryEventSerializer(serializers.Serializer[Event]): 35 | id = serializers.IntegerField() 36 | name = serializers.CharField() 37 | 38 | def create(self, validated_data: ValidatedEventDataDict) -> Event: 39 | event, _ = Event.objects.update_or_create( 40 | event_id=validated_data["id"], 41 | defaults={ 42 | "name": validated_data["name"], 43 | "provider": self.context["provider"], 44 | }, 45 | ) 46 | 47 | return event 48 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder 2 | 3 | # Get the required UV tokens for the project 4 | ARG UV_INDEX_TSD_USERNAME 5 | ARG UV_INDEX_TSD_PASSWORD 6 | 7 | # Set the UV tokens as environment variables 8 | ENV UV_INDEX_TSD_USERNAME=$UV_INDEX_TSD_USERNAME 9 | ENV UV_INDEX_TSD_PASSWORD=$UV_INDEX_TSD_PASSWORD 10 | 11 | # Set the environment variables for the UV build 12 | ENV UV_COMPILE_BYTECODE=1 13 | ENV UV_LINK_MODE=copy 14 | 15 | WORKDIR /app 16 | 17 | # Install and cache the dependencies 18 | RUN --mount=type=cache,target=/root/.cache/uv \ 19 | --mount=type=bind,source=uv.lock,target=uv.lock \ 20 | --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ 21 | uv sync --frozen --no-dev 22 | 23 | # Copy the project files into the image and sync it to the virtual environment 24 | ADD src/ /app/src/ 25 | 26 | FROM python:3.13-slim-bookworm 27 | 28 | # Set environment variables 29 | ENV PYTHONDONTWRITEBYTECODE=1 30 | ENV PYTHONUNBUFFERED=1 31 | ENV PATH="/app/.venv/bin:$PATH" 32 | ENV DJANGO_SETTINGS_MODULE=snapy.settings 33 | 34 | # Using the www-data user for security. This user is already present in the base image. 35 | ENV APP_USER=www-data 36 | 37 | WORKDIR /app/src 38 | 39 | # Copy the project files into the image 40 | COPY --from=builder --chown=$APP_USER:$APP_USER /app /app 41 | 42 | USER $APP_USER 43 | EXPOSE 8000 44 | 45 | CMD ["opentelemetry-instrument", "gunicorn", "snapy.wsgi", "-c", "gunicorn.config.py"] 46 | -------------------------------------------------------------------------------- /.github/workflows/staging.yml: -------------------------------------------------------------------------------- 1 | name: Build and deploy to staging 2 | on: 3 | release: 4 | types: 5 | - prereleased 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: read 12 | packages: write 13 | 14 | steps: 15 | - name: Check out the repo 16 | uses: actions/checkout@v6 17 | 18 | - name: Build Image 19 | uses: ./.github/actions/build-image 20 | with: 21 | version: ${{ github.event.release.tag_name }} 22 | token: ${{ secrets.GITHUB_TOKEN }} 23 | repository_username: ${{ secrets.UV_INDEX_TSD_USERNAME }} 24 | repository_password: ${{ secrets.UV_INDEX_TSD_PASSWORD }} 25 | 26 | deploy: 27 | runs-on: ubuntu-latest 28 | needs: build 29 | environment: TSD Staging 30 | 31 | steps: 32 | - name: Checkout the deployments repo 33 | uses: actions/checkout@v6 34 | with: 35 | repository: 'TheSpaceDevs/TSD-Deployments' 36 | path: 'deployments' 37 | token: ${{ secrets.PAT }} # https://github.com/actions/checkout?tab=readme-ov-file#checkout-multiple-repos-private 38 | 39 | - name: Helm 3 40 | uses: WyriHaximus/github-action-helm3@v4.0.2 41 | with: 42 | kubeconfig: '${{ secrets.KUBECONFIG }}' 43 | overrule_existing_kubeconfig: "true" 44 | exec: helm upgrade snapi-staging -f ./deployments/snapi-django/values.yaml -f ./deployments/snapi-django/values-staging.yaml --set image.tag=${{ github.event.release.tag_name }} ./deployments/snapi-django 45 | -------------------------------------------------------------------------------- /.github/workflows/production.yml: -------------------------------------------------------------------------------- 1 | name: Build and deploy to production 2 | on: 3 | release: 4 | types: 5 | - released 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: read 12 | packages: write 13 | 14 | steps: 15 | - name: Check out the repo 16 | uses: actions/checkout@v6 17 | 18 | - name: Build Image 19 | uses: ./.github/actions/build-image 20 | with: 21 | version: ${{ github.event.release.tag_name }} 22 | token: ${{ secrets.GITHUB_TOKEN }} 23 | repository_username: ${{ secrets.UV_INDEX_TSD_USERNAME }} 24 | repository_password: ${{ secrets.UV_INDEX_TSD_PASSWORD }} 25 | 26 | 27 | deploy: 28 | runs-on: ubuntu-latest 29 | needs: build 30 | environment: TSD Production 31 | 32 | steps: 33 | - name: Checkout the deployments repo 34 | uses: actions/checkout@v6 35 | with: 36 | repository: 'TheSpaceDevs/TSD-Deployments' 37 | path: 'deployments' 38 | token: ${{ secrets.PAT }} # https://github.com/actions/checkout?tab=readme-ov-file#checkout-multiple-repos-private 39 | 40 | - name: Helm 3 41 | uses: WyriHaximus/github-action-helm3@v4.0.2 42 | with: 43 | kubeconfig: '${{ secrets.KUBECONFIG }}' 44 | overrule_existing_kubeconfig: "true" 45 | exec: helm upgrade snapi-prod -f ./deployments/snapi-django/values.yaml -f ./deployments/snapi-django/values-prod.yaml --set image.tag=${{ github.event.release.tag_name }} ./deployments/snapi-django 46 | -------------------------------------------------------------------------------- /src/snapy/urls.py: -------------------------------------------------------------------------------- 1 | """snapy URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/4.1/topics/http/urls/ 5 | 6 | Examples 7 | -------- 8 | Function views 9 | 1. Add an import: from my_app import views 10 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 11 | Class-based views 12 | 1. Add an import: from other_app.views import Home 13 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 14 | Including another URLconf 15 | 1. Import the include() function: from django.urls import include, path 16 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 17 | 18 | """ 19 | 20 | from django.conf import settings 21 | from django.contrib import admin 22 | from django.urls import include, re_path 23 | from django.views.decorators.csrf import csrf_exempt 24 | from graphene_django.views import GraphQLView # type: ignore 25 | 26 | urlpatterns = [ 27 | # Jet URLs 28 | re_path(r"^v4/jet/dashboard/", include("jet.dashboard.urls", "jet-dashboard")), # Django JET dashboard URLS 29 | re_path(r"^v4/jet/", include("jet.urls", "jet")), # Django JET URLS 30 | # GraphQL URLs 31 | re_path(r"^v4/graphql/", csrf_exempt(GraphQLView.as_view(graphiql=True))), 32 | # API URLs 33 | re_path(r"^v4/", include(("api.urls", "api"), namespace="v4")), 34 | # Non v4 URLs 35 | re_path(r"health/", include("health_check.urls")), 36 | re_path(r"admin/", admin.site.urls), 37 | ] 38 | 39 | if settings.DEBUG: 40 | from debug_toolbar.toolbar import debug_toolbar_urls # type: ignore 41 | 42 | urlpatterns += debug_toolbar_urls() 43 | -------------------------------------------------------------------------------- /.github/actions/build-image/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Build Image' 2 | description: 'Builds, tags and pushes an image to the configured registry' 3 | inputs: 4 | version: # id of input 5 | description: 'Version that will be used to tag the image. Should be the version of the API.' 6 | required: true 7 | token: 8 | description: 'GitHub token to use for the action' 9 | required: true 10 | repository_username: 11 | description: 'Username of the repository' 12 | required: true 13 | repository_password: 14 | description: 'Password of the repository' 15 | required: true 16 | runs: 17 | using: "composite" 18 | steps: 19 | - name: Update version in pyproject.toml 20 | shell: bash 21 | run: | 22 | sed -i "s/version = \".*\"/version = \"${{ inputs.version }}\"/g" pyproject.toml 23 | 24 | - name: Docker meta 25 | id: meta 26 | uses: docker/metadata-action@v5 27 | with: 28 | images: | 29 | ghcr.io/TheSpaceDevs/spaceflightnewsapi 30 | tags: ${{ inputs.version }} 31 | 32 | - name: Set up Docker Buildx 33 | uses: docker/setup-buildx-action@v3 34 | 35 | - name: Login to GitHub Container Registry 36 | uses: docker/login-action@v3 37 | with: 38 | registry: ghcr.io 39 | username: ${{ github.actor }} 40 | password: ${{ inputs.token }} 41 | 42 | - name: Build and push 43 | id: docker_build 44 | uses: docker/build-push-action@v6 45 | with: 46 | cache-from: type=gha 47 | cache-to: type=gha,mode=max 48 | push: true 49 | build-args: | 50 | UV_INDEX_TSD_USERNAME=${{ inputs.repository_username }} 51 | UV_INDEX_TSD_PASSWORD=${{ inputs.repository_password }} 52 | context: . 53 | tags: ${{ steps.meta.outputs.tags }} 54 | labels: ${{ steps.meta.outputs.labels }} 55 | -------------------------------------------------------------------------------- /src/api/migrations/0005_add_author_and_socials.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.16 on 2024-12-21 07:17 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("api", "0004_alter_article_updated_at_alter_blog_updated_at_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="Socials", 15 | fields=[ 16 | ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 17 | ("x", models.URLField(blank=True)), 18 | ("youtube", models.URLField(blank=True)), 19 | ("instagram", models.URLField(blank=True)), 20 | ], 21 | options={ 22 | "verbose_name_plural": "Socials", 23 | }, 24 | ), 25 | migrations.CreateModel( 26 | name="Author", 27 | fields=[ 28 | ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 29 | ("name", models.CharField(max_length=250)), 30 | ( 31 | "socials", 32 | models.ForeignKey( 33 | blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to="api.socials" 34 | ), 35 | ), 36 | ], 37 | ), 38 | migrations.AddField( 39 | model_name="article", 40 | name="authors", 41 | field=models.ManyToManyField(blank=True, to="api.author"), 42 | ), 43 | migrations.AddField( 44 | model_name="blog", 45 | name="authors", 46 | field=models.ManyToManyField(blank=True, to="api.author"), 47 | ), 48 | migrations.AddField( 49 | model_name="report", 50 | name="authors", 51 | field=models.ManyToManyField(blank=True, to="api.author"), 52 | ), 53 | ] 54 | -------------------------------------------------------------------------------- /src/api/schema.py: -------------------------------------------------------------------------------- 1 | # type: ignore 2 | 3 | from graphene import ObjectType, relay 4 | from graphene_django import DjangoObjectType 5 | from graphene_django.filter import DjangoFilterConnectionField 6 | 7 | from api.models import Article, Blog, Event, Launch, NewsSite, Report 8 | from api.views.filters import BaseFilter, DocsFilter 9 | 10 | 11 | class ArticleType(DjangoObjectType): 12 | class Meta: 13 | model = Article 14 | exclude = ["is_deleted"] 15 | filterset_class = DocsFilter 16 | interfaces = (relay.Node,) 17 | 18 | @classmethod 19 | def get_queryset(cls, queryset, info): 20 | return queryset.order_by("-published_at") 21 | 22 | 23 | class BlogType(DjangoObjectType): 24 | class Meta: 25 | model = Blog 26 | exclude = ["is_deleted"] 27 | filterset_class = DocsFilter 28 | interfaces = (relay.Node,) 29 | 30 | @classmethod 31 | def get_queryset(cls, queryset, info): 32 | return queryset.order_by("-published_at") 33 | 34 | 35 | class ReportType(DjangoObjectType): 36 | class Meta: 37 | model = Report 38 | exclude = ["is_deleted"] 39 | filterset_class = BaseFilter 40 | interfaces = (relay.Node,) 41 | 42 | @classmethod 43 | def get_queryset(cls, queryset, info): 44 | return queryset.order_by("-published_at") 45 | 46 | 47 | class LaunchType(DjangoObjectType): 48 | class Meta: 49 | model = Launch 50 | interfaces = (relay.Node,) 51 | 52 | 53 | class EventType(DjangoObjectType): 54 | class Meta: 55 | model = Event 56 | interfaces = (relay.Node,) 57 | 58 | 59 | class NewsSiteType(DjangoObjectType): 60 | class Meta: 61 | model = NewsSite 62 | interfaces = (relay.Node,) 63 | 64 | 65 | class Query(ObjectType): 66 | article = relay.Node.Field(ArticleType) 67 | all_articles = DjangoFilterConnectionField(ArticleType) 68 | 69 | blog = relay.Node.Field(BlogType) 70 | all_blogs = DjangoFilterConnectionField(BlogType) 71 | 72 | report = relay.Node.Field(ReportType) 73 | all_reports = DjangoFilterConnectionField(ReportType) 74 | -------------------------------------------------------------------------------- /src/importer/management/commands/_launch_events.py: -------------------------------------------------------------------------------- 1 | from logging import INFO, WARN, basicConfig, getLogger 2 | from typing import TypedDict 3 | 4 | import httpx 5 | from django.conf import settings 6 | 7 | from api.models import Provider 8 | from importer.serializers import LaunchLibraryEventSerializer, LaunchLibraryLaunchSerializer 9 | 10 | LOGGER = getLogger(__name__) 11 | basicConfig(level=INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") 12 | 13 | # disable httpx INFO logging 14 | httpx_logger = getLogger("httpx") 15 | httpx_logger.setLevel(WARN) 16 | 17 | try: 18 | provider = Provider.objects.get(name="Launch Library 2") 19 | except Provider.DoesNotExist: 20 | raise ValueError("Launch Library 2 provider does not exist. Please create it before running this command.") 21 | 22 | 23 | class ClientOptions(TypedDict): 24 | base_url: str 25 | headers: dict[str, str] 26 | 27 | 28 | # Not setting the timout on this dict since Bandit doesn't like it 29 | client_options: ClientOptions = { 30 | "base_url": settings.LL_URL, 31 | "headers": { 32 | "Authorization": f"Token {settings.LL_TOKEN}", 33 | "User-Agent": f"SNAPI {settings.VERSION}", 34 | }, 35 | } 36 | 37 | 38 | def fetch_launches() -> None: 39 | next_url = "/launches/" 40 | 41 | with httpx.Client(base_url=client_options["base_url"], timeout=1440, headers=client_options["headers"]) as client: 42 | while next_url: 43 | LOGGER.info(f"Fetching launches from {next_url}") 44 | 45 | response = client.get(url=next_url) 46 | response.raise_for_status() 47 | 48 | data = response.json() 49 | 50 | for launch in data["results"]: 51 | launch = LaunchLibraryLaunchSerializer(data=launch, context={"provider": provider}) 52 | launch.is_valid(raise_exception=True) 53 | launch.save() 54 | 55 | next_url = data["next"] 56 | 57 | LOGGER.info("Finished fetching launches.") 58 | 59 | 60 | def fetch_events() -> None: 61 | next_url = "/events/" 62 | 63 | with httpx.Client(base_url=client_options["base_url"], timeout=1400, headers=client_options["headers"]) as client: 64 | while next_url: 65 | LOGGER.info(f"Fetching events from {next_url}") 66 | response = client.get(url=next_url) 67 | response.raise_for_status() 68 | 69 | data = response.json() 70 | 71 | for event in data["results"]: 72 | event = LaunchLibraryEventSerializer(data=event, context={"provider": provider}) 73 | event.is_valid(raise_exception=True) 74 | event.save() 75 | 76 | next_url = data["next"] 77 | 78 | LOGGER.info("Finished fetching events.") 79 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Linting and Testing 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | 7 | env: 8 | DEBUG: "true" 9 | SECRET_KEY: "some-insecure-key" 10 | DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/postgres" 11 | CSRF_TRUSTED_ORIGIN: http://127.0.0.1 12 | UV_INDEX_TSD_USERNAME: ${{ secrets.UV_INDEX_TSD_USERNAME }} 13 | UV_INDEX_TSD_PASSWORD: ${{ secrets.UV_INDEX_TSD_PASSWORD }} 14 | 15 | AWS_STORAGE_BUCKET_NAME: ${{ secrets.AWS_STORAGE_BUCKET_NAME }} 16 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 17 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 18 | 19 | jobs: 20 | linters: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Check out the repo 24 | uses: actions/checkout@v6 25 | 26 | - name: Setup Python and Poetry 27 | uses: './.github/actions/setup-python' 28 | with: 29 | python-version: "3.12" 30 | 31 | - name: Install dependencies 32 | run: uv sync --frozen 33 | 34 | - name: Run linters 35 | run: | 36 | mkdir test_results 37 | uv run ruff check . 38 | uv run ruff format --check . 39 | uv run mypy . 40 | working-directory: src/ 41 | 42 | tests: 43 | runs-on: ubuntu-latest 44 | services: 45 | # Label used to access the service container 46 | postgres: 47 | # Docker Hub image 48 | image: postgres 49 | # Provide the password for postgres 50 | env: 51 | POSTGRES_USER: postgres 52 | POSTGRES_PASSWORD: postgres 53 | POSTGRES_DB: postgres 54 | # Set health checks to wait until postgres has started 55 | options: >- 56 | --health-cmd pg_isready 57 | --health-interval 10s 58 | --health-timeout 5s 59 | --health-retries 5 60 | ports: 61 | # Maps tcp port 5432 on service container to the host 62 | - 5432:5432 63 | 64 | steps: 65 | - name: Check out the repo 66 | uses: actions/checkout@v6 67 | 68 | - name: Setup Python & Poetry 69 | uses: './.github/actions/setup-python' 70 | with: 71 | python-version: "3.12" 72 | 73 | - name: Install dependencies 74 | run: | 75 | uv sync --frozen 76 | 77 | - name: Run tests 78 | run: uv run pytest --junitxml=test_results/TEST-pytest.xml 79 | working-directory: src/ 80 | 81 | - name: Publish test report 82 | uses: mikepenz/action-junit-report@v6 83 | if: success() || failure() # always run even if the previous step fails 84 | with: 85 | report_paths: "test_results/TEST-*.xml" 86 | 87 | # Try to build the docker image to ensure it works 88 | test-docker-image: 89 | runs-on: ubuntu-latest 90 | steps: 91 | - name: Check out the repo 92 | uses: actions/checkout@v6 93 | 94 | - name: Set up Docker Buildx 95 | uses: docker/setup-buildx-action@v3 96 | 97 | - name: Build the image 98 | id: docker_build 99 | uses: docker/build-push-action@v6 100 | with: 101 | cache-from: type=gha 102 | cache-to: type=gha,mode=max 103 | push: false 104 | build-args: | 105 | UV_INDEX_TSD_USERNAME=${{ env.UV_INDEX_TSD_USERNAME }} 106 | UV_INDEX_TSD_PASSWORD=${{ env.UV_INDEX_TSD_PASSWORD }} 107 | context: . 108 | -------------------------------------------------------------------------------- /src/importer/management/commands/_news_items.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from functools import lru_cache 4 | 5 | from harvester import sources 6 | from harvester.schemas import ArticleSchema, BlogSchema, ReportSchema 7 | 8 | from api.models import Article, Author, Blog, NewsSite, Report 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | @lru_cache 14 | def _get_author(author_name: str) -> Author: 15 | author_name = author_name.strip() 16 | author = Author.objects.filter(name=author_name).first() 17 | if not author: 18 | logger.info(f"Adding author: {author_name}") 19 | author = Author.objects.create(name=author_name) 20 | 21 | return author 22 | 23 | 24 | @lru_cache 25 | def _get_news_site(id: int) -> NewsSite: 26 | news_site = NewsSite.objects.get(id=id) 27 | return news_site 28 | 29 | 30 | def process_article(article: ArticleSchema) -> None: 31 | # Get the news site 32 | news_site = _get_news_site(id=article.news_site_id) 33 | author = _get_author(author_name=article.author) 34 | 35 | # Check if the article already exists 36 | if Article.objects.filter(url=article.url).exists(): 37 | logger.debug(f"Article {article.title} already exists") 38 | return 39 | 40 | logger.info(f"Adding article: {article.title}") 41 | 42 | # Create the article 43 | new_article = Article.objects.create( 44 | title=article.title, 45 | url=str(article.url), 46 | news_site=news_site, 47 | published_at=article.published_at, 48 | summary=article.summary, 49 | image_url=str(article.image_url), 50 | ) 51 | 52 | new_article.authors.add(author) 53 | 54 | 55 | def process_blog(blog: BlogSchema) -> None: 56 | # Get the news site 57 | news_site = _get_news_site(id=blog.news_site_id) 58 | author = _get_author(author_name=blog.author) 59 | 60 | # Check if the blog already exists 61 | if Blog.objects.filter(url=blog.url).exists(): 62 | logger.debug(f"Blog {blog.title} already exists") 63 | return 64 | 65 | logger.info(f"Adding blog: {blog.title}") 66 | 67 | # Create the blog 68 | new_blog = Blog.objects.create( 69 | title=blog.title, 70 | url=str(blog.url), 71 | news_site=news_site, 72 | published_at=blog.published_at, 73 | summary=blog.summary, 74 | image_url=str(blog.image_url), 75 | ) 76 | 77 | new_blog.authors.add(author) 78 | 79 | 80 | def process_report(report: ReportSchema) -> None: 81 | # Get the news site 82 | news_site = _get_news_site(id=report.news_site_id) 83 | author = _get_author(author_name=report.author) 84 | 85 | # Check if the report already exists 86 | if Report.objects.filter(url=report.url).exists(): 87 | logger.debug(f"Report {report.title} already exists") 88 | return 89 | 90 | logger.info(f"Adding report: {report.title}") 91 | 92 | # Create the report 93 | new_report = Report.objects.create( 94 | title=report.title, 95 | url=str(report.url), 96 | news_site=news_site, 97 | published_at=report.published_at, 98 | summary=report.summary, 99 | image_url=str(report.image_url), 100 | ) 101 | 102 | new_report.authors.add(author) 103 | 104 | 105 | def fetch_news() -> None: 106 | logger.info("Fetching news") 107 | starttime = time.time() 108 | 109 | for source in sources: 110 | try: 111 | data = source.harvest() 112 | 113 | if data.articles: 114 | for article in data.articles: 115 | process_article(article=article) 116 | 117 | if data.blogs: 118 | for blog in data.blogs: 119 | process_blog(blog=blog) 120 | 121 | if data.reports: 122 | for report in data.reports: 123 | process_report(report=report) 124 | 125 | except Exception as e: 126 | logger.error(f"Error processing {source}: {e}") 127 | 128 | logger.info(f"Finished fetching news in {time.time() - starttime} seconds") 129 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Cover](https://raw.githubusercontent.com/TheSpaceDevs/spaceflightnewsapi/main/.github/profile/assets/snapi_poster.png) 2 | 3 | [![Website](https://raw.githubusercontent.com/TheSpaceDevs/spaceflightnewsapi/main/.github/profile/assets/badge_snapi_website.svg)](https://spaceflightnewsapi.net/) 4 | [![Documentation](https://raw.githubusercontent.com/TheSpaceDevs/spaceflightnewsapi/main/.github/profile/assets/badge_snapi_doc.svg)](https://api.spaceflightnewsapi.net/v4/docs) 5 | [![Version](https://img.shields.io/github/v/release/TheSpaceDevs/spaceflightnewsapi?style=for-the-badge)](https://github.com/TheSpaceDevs/spaceflightnewsapi/releases/tag/v4.0.4) 6 | [![Discord](https://img.shields.io/badge/Discord-%237289DA.svg?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/p7ntkNA) 7 | [![Twitter](https://img.shields.io/badge/Twitter-%231DA1F2.svg?style=for-the-badge&logo=Twitter&logoColor=white)](https://twitter.com/the_snapi) 8 | [![Patreon](https://img.shields.io/badge/Patreon-F96854?style=for-the-badge&logo=patreon&logoColor=white)](https://www.patreon.com/TheSpaceDevs) 9 | 10 | [![security: bandit](https://img.shields.io/badge/security-bandit-yellow.svg)](https://github.com/PyCQA/bandit) 11 | 12 | # Spaceflight News API 13 | 14 | The Spaceflight News API was created as a solution for my problem when I wanted to develop an app for Spaceflight News: 15 | many (great!) news sites with different APIs. 16 | 17 | To make it easier for myself, I began a project that would aggregate metadata from those news sites and publish them 18 | through an API. Since there are others that might benefit from this API, I decided make the API publicly available. 19 | 20 | There are great apps out on the internet, that are connected to services like . By making this 21 | API available to everyone, I hope to open new doors for the developers of these apps. 22 | 23 | ## Documentation 📖 24 | 25 | The documentation is generated from the code, and can be found at . 26 | 27 | ## Evolution 📈 28 | 29 | ### Version 2 30 | 31 | In July 2020, Launch Library 2.0 was released, within the new The Space Devs API 32 | group. I've joined this group as a partner developer, and started finalizing SNAPI 2.0. 33 | 34 | Version 2.0 of SNAPI is a rewrite of the entire API using Strapi as a backend, with custom endpoints written by me. 35 | SNAPI 2 sets the stage for new features to come and focuses on bringing the existing features to the new format. 36 | 37 | ### Version 3 38 | 39 | In the Spring of 2021, Strapi announced that they would retire support for MongoDB. Since SNAPI was using MongoDB as the 40 | database, this had quite a big impact. 41 | Version 3 of the API is exactly the same as version 2 (in terms of the response), except the IDs. These changed from 42 | ObjectIDs (strings) to integers. 43 | 44 | ### Version 4 45 | 46 | In 2023 SNAPI V4 launched, completely re-written in Python (Django) for various reasons. 47 | Using proven libraries, this version is focussed on long-term stability and maintainability. 48 | 49 | ## Launch Library 2 integration 🚀 50 | 51 | Starting from version 2, we now have Launch Library 2 API integration. This 52 | way you can easily get news related to a specific launch. 53 | A nice to have if you want to have a "related news/launches" section in your app! 54 | 55 | ## Currently imported news sites 🌐 56 | 57 |
58 | Expand 59 | 60 | - AmericaSpace 61 | - Arstechnica 62 | - Blue Origin 63 | - CNBC 64 | - ESA 65 | - ElonX 66 | - Euronews 67 | - European Spaceflight 68 | - Jet Propulsion Laboratory 69 | - NASA 70 | - NASASpaceflight 71 | - National Geographic 72 | - National Space Society 73 | - Phys 74 | - Planetary Society 75 | - Reuters 76 | - Space.com 77 | - SpaceFlight Insider 78 | - SpaceNews 79 | - SpacePolicyOnline.com 80 | - SpaceX 81 | - Spaceflight Now 82 | - SyFy 83 | - TechCrunch 84 | - Teslarati 85 | - The Drive 86 | - The Japan Times 87 | - The Launch Pad 88 | - The National 89 | - The New York Times 90 | - The Space Devs 91 | - The Space Review 92 | - The Verge 93 | - The Wall Street Journal 94 | - United Launch Alliance 95 | - Virgin Galactic 96 | 97 |
98 | 99 | ## Changelog 📝 100 | 101 |
102 | Expand 103 | 104 | # V4.0.5 105 | 106 | - Package updates 107 | - Migrated to Python 3.12 108 | 109 | # V4.0.0 110 | 111 | - Rewritten in Python and Django. 112 | 113 | # V3.4.0 114 | 115 | - Package updates 116 | - Sentry fixes 117 | 118 | # V3.0.0 119 | 120 | - Package updates 121 | 122 | ### V3.2.0 123 | 124 | - Various Sentry issues fixed 125 | 126 | ### V3.1.0 127 | 128 | - Strapi updates 129 | - Sentry updates 130 | - Admin interface updates 131 | 132 | ### V3.0.0 133 | 134 | - Switch to use Postgres as database 135 | 136 | ### V2.3.0 137 | 138 | - The lost "article per (LL2) event" endpoint is back 139 | - Changed the G4L logo on the site 140 | - Added Sentry again, via the new Strapi plugin 141 | - Changed from amqplib to amqp-connection-manager 142 | - Updated to Strapi 3.5.3 143 | 144 | ### v2.2.0 145 | 146 | - Dependency updates 147 | - Code cleanup 148 | - Admin side of things 149 | 150 | ### v2.1.0 151 | 152 | - Backend changes on how new content is processed 153 | - Package updates 154 | 155 | ### v2.0.0 156 | 157 | - Complete rewrite of the app, focusing on existing features 158 | 159 |
160 | 161 | ## Showcase 162 | 163 | For a list of users, please visit the [users page](https://www.spaceflightnewsapi.net/users/). 164 | On the [profile page](https://github.com/TheSpaceDevs#api-showcase---spaceflight-news-api-) of TheSpaceDevs 165 | organisation, you'll also find a simple showcase of the API. 166 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "snapy" 3 | version = "4.27.0" 4 | description = "Spaceflight News API (SNAPI) enables developers to add the latest spaceflight news to their apps." 5 | authors = [{ name = "Derk Weijers", email = "derk@weijers.xyz" }] 6 | readme = "README.md" 7 | requires-python = ">=3.13,<3.14" # Pin to Python 3.13 for compatibility with Django 5.2 8 | dependencies = [ 9 | "boto3>=1.35.23", 10 | "django-cors-headers>=4.4.0", 11 | "django-filter>=24.3", 12 | "django-health-check>=3.18.3", 13 | "django-jet-reboot>=1.3.9", 14 | "django-storages[s3]>=1.14.4", 15 | "django-stubs[compatible-mypy]>=5.2,<5.3", # Pinned to 5.2.* for compatibility with Django 5.2 16 | "django-stubs-ext>=5.2,<5.3", # Pinned to 5.2.* for compatibility with Django 5.2 17 | "django>=5.2,<5.3", 18 | "djangorestframework>=3.15.2", 19 | "drf-spectacular>=0.27.2", 20 | "environs[django]>=11.0.0", 21 | "feedparser>=6.0.11", 22 | "gunicorn>=23.0.0", 23 | "httpx>=0.27.2", 24 | "markdown>=3.7", 25 | "psycopg2-binary>=2.9.9", 26 | "pyyaml>=6.0.2", 27 | "sentry-sdk>=2.14.0", 28 | "uritemplate>=4.1.1", 29 | "harvester>=0.17.0", 30 | "graphene-django>=3.2.2", 31 | "redis>=5.2.0", 32 | "hiredis>=3.1.0", 33 | "django-cachalot>=2.8.0", 34 | "django-redis>=6.0.0", 35 | "opentelemetry-distro>=0.58b0", 36 | "opentelemetry-exporter-otlp>=1.37.0", 37 | "opentelemetry-instrumentation-asyncio==0.58b0", 38 | "opentelemetry-instrumentation-dbapi==0.58b0", 39 | "opentelemetry-instrumentation-logging==0.58b0", 40 | "opentelemetry-instrumentation-sqlite3==0.58b0", 41 | "opentelemetry-instrumentation-threading==0.58b0", 42 | "opentelemetry-instrumentation-urllib==0.58b0", 43 | "opentelemetry-instrumentation-wsgi==0.58b0", 44 | "opentelemetry-instrumentation-asgi==0.58b0", 45 | "opentelemetry-instrumentation-boto3sqs==0.58b0", 46 | "opentelemetry-instrumentation-botocore==0.58b0", 47 | "opentelemetry-instrumentation-click==0.58b0", 48 | "opentelemetry-instrumentation-django==0.58b0", 49 | "opentelemetry-instrumentation-grpc==0.58b0", 50 | "opentelemetry-instrumentation-httpx==0.58b0", 51 | "opentelemetry-instrumentation-jinja2==0.58b0", 52 | "opentelemetry-instrumentation-psycopg2==0.58b0", 53 | "opentelemetry-instrumentation-redis==0.58b0", 54 | "opentelemetry-instrumentation-requests==0.58b0", 55 | "opentelemetry-instrumentation-tortoiseorm==0.58b0", 56 | "opentelemetry-instrumentation-urllib3==0.58b0", 57 | ] 58 | 59 | [dependency-groups] 60 | dev = [ 61 | "bandit[toml]>=1.7.9", 62 | "commitizen>=4.0.0", 63 | "coverage>=7.6.1", 64 | "django-debug-toolbar>=4.4.6", 65 | "django-filter-stubs>=0.1.3", 66 | "djangorestframework-stubs[compatible-mypy]>=3.16", 67 | "mypy>=1.13,<1.20", # Pinned range for compatibility with Django 5.2 68 | "peek-python>=25.0.7", 69 | "pytest>=8.3.3", 70 | "pytest-cov>=5.0.0", 71 | "pytest-django>=4.9.0", 72 | "python-semantic-release>=9.14.0", 73 | "pyupgrade>=3.17.0", 74 | "ruff>=0.6.6", 75 | ] 76 | 77 | [tool.mypy] 78 | strict = true 79 | plugins = ["mypy_django_plugin.main", "mypy_drf_plugin.main"] 80 | mypy_path = "src/" 81 | 82 | [tool.django-stubs] 83 | django_settings_module = "snapy.settings" 84 | 85 | [tool.ruff] 86 | extend-exclude = ["src/snapy/settings.py"] 87 | line-length = 121 88 | 89 | [tool.ruff.lint] 90 | select = ["E", "W", "F", "I", "DJ", "UP"] 91 | 92 | [tool.bandit] 93 | exclude_dirs = ["tests/", ".github/", ".venv/"] 94 | 95 | [tool.pytest.ini_options] 96 | DJANGO_SETTINGS_MODULE = "snapy.settings" 97 | 98 | [[tool.uv.index]] 99 | name = "tsd" 100 | url = "https://pypi.thespacedevs.com/simple/" 101 | explicit = true 102 | 103 | [tool.uv.sources] 104 | harvester = { index = "tsd" } 105 | 106 | # Mainly as fallback, as Python Semenatic Release will also handle this. 107 | [tool.commitizen] 108 | name = "cz_conventional_commits" 109 | tag_format = "$version" 110 | version_scheme = "semver" 111 | version_provider = "pep621" 112 | update_changelog_on_bump = false 113 | 114 | [tool.semantic_release] 115 | assets = [] 116 | commit_message = "build: {version}\n\nAutomatically generated by python-semantic-release" 117 | commit_parser = "conventional" 118 | logging_use_named_masks = false 119 | major_on_zero = true 120 | allow_zero_version = true 121 | no_git_verify = false 122 | tag_format = "{version}" 123 | version_toml = ["pyproject.toml:project.version"] 124 | version_variables = ["src/snapy/__init__.py:__version__"] 125 | 126 | [tool.semantic_release.branches.main] 127 | match = "main" 128 | prerelease_token = "rc" 129 | prerelease = false 130 | 131 | [tool.semantic_release.branches.staging] 132 | match = "staging" 133 | prerelease_token = "rc" 134 | prerelease = true 135 | 136 | [tool.semantic_release.changelog] 137 | exclude_commit_patterns = [] 138 | mode = "init" 139 | insertion_flag = "" 140 | template_dir = "templates" 141 | 142 | [tool.semantic_release.changelog.default_templates] 143 | changelog_file = "CHANGELOG.md" 144 | output_format = "md" 145 | 146 | [tool.semantic_release.changelog.environment] 147 | block_start_string = "{%" 148 | block_end_string = "%}" 149 | variable_start_string = "{{" 150 | variable_end_string = "}}" 151 | comment_start_string = "{#" 152 | comment_end_string = "#}" 153 | trim_blocks = false 154 | lstrip_blocks = false 155 | newline_sequence = "\n" 156 | keep_trailing_newline = false 157 | extensions = [] 158 | autoescape = false 159 | 160 | [tool.semantic_release.commit_author] 161 | env = "GIT_COMMIT_AUTHOR" 162 | default = "semantic-release " 163 | 164 | [tool.semantic_release.commit_parser_options] 165 | minor_tags = ["feat", "chore"] 166 | patch_tags = ["fix", "perf"] 167 | allowed_tags = [ 168 | "feat", 169 | "fix", 170 | "perf", 171 | "build", 172 | "chore", 173 | "ci", 174 | "docs", 175 | "style", 176 | "refactor", 177 | "test", 178 | ] 179 | default_bump_level = 0 180 | 181 | [tool.semantic_release.remote] 182 | name = "origin" 183 | type = "github" 184 | ignore_token_for_push = false 185 | insecure = false 186 | 187 | [tool.semantic_release.publish] 188 | dist_glob_patterns = ["dist/*"] 189 | upload_to_vcs_release = true 190 | -------------------------------------------------------------------------------- /src/static/jet/css/themes/dark/jquery-ui.theme.css: -------------------------------------------------------------------------------- 1 | .hidden{display:none}.clear-list{margin:0;padding:0;list-style:none}.fl{float:left}.fr{float:right}.cf:before,.cf:after{content:"";display:table}.cf:after{clear:both}.p10{padding:10px;padding:0.71429rem}.p20{padding:20px;padding:1.42857rem}.p30{padding:30px;padding:2.14286rem}.p40{padding:40px;padding:2.85714rem}.p50{padding:50px;padding:3.57143rem}.p60{padding:60px;padding:4.28571rem}.p70{padding:70px;padding:5rem}.p80{padding:80px;padding:5.71429rem}.pt10{padding-top:10px;padding-top:0.71429rem}.pt20{padding-top:20px;padding-top:1.42857rem}.pt30{padding-top:30px;padding-top:2.14286rem}.pt40{padding-top:40px;padding-top:2.85714rem}.pt50{padding-top:50px;padding-top:3.57143rem}.pt60{padding-top:60px;padding-top:4.28571rem}.pt70{padding-top:70px;padding-top:5rem}.pt80{padding-top:80px;padding-top:5.71429rem}.pr10{padding-right:10px;padding-right:0.71429rem}.pr20{padding-right:20px;padding-right:1.42857rem}.pr30{padding-right:30px;padding-right:2.14286rem}.pr40{padding-right:40px;padding-right:2.85714rem}.pr50{padding-right:50px;padding-right:3.57143rem}.pr60{padding-right:60px;padding-right:4.28571rem}.pr70{padding-right:70px;padding-right:5rem}.pr80{padding-right:80px;padding-right:5.71429rem}.pb10{padding-bottom:10px;padding-bottom:0.71429rem}.pb20{padding-bottom:20px;padding-bottom:1.42857rem}.pb30{padding-bottom:30px;padding-bottom:2.14286rem}.pb40{padding-bottom:40px;padding-bottom:2.85714rem}.pb50{padding-bottom:50px;padding-bottom:3.57143rem}.pb60{padding-bottom:60px;padding-bottom:4.28571rem}.pb70{padding-bottom:70px;padding-bottom:5rem}.pb80{padding-bottom:80px;padding-bottom:5.71429rem}.pl10{padding-left:10px;padding-left:0.71429rem}.pl20{padding-left:20px;padding-left:1.42857rem}.pl30{padding-left:30px;padding-left:2.14286rem}.pl40{padding-left:40px;padding-left:2.85714rem}.pl50{padding-left:50px;padding-left:3.57143rem}.pl60{padding-left:60px;padding-left:4.28571rem}.pl70{padding-left:70px;padding-left:5rem}.pl80{padding-left:80px;padding-left:5.71429rem}.m10{margin:10px;margin:0.71429rem}.m20{margin:20px;margin:1.42857rem}.m30{margin:30px;margin:2.14286rem}.m40{margin:40px;margin:2.85714rem}.m50{margin:50px;margin:3.57143rem}.m60{margin:60px;margin:4.28571rem}.m70{margin:70px;margin:5rem}.m80{margin:80px;margin:5.71429rem}.mt10{margin-top:10px;margin-top:0.71429rem}.mt20{margin-top:20px;margin-top:1.42857rem}.mt30{margin-top:30px;margin-top:2.14286rem}.mt40{margin-top:40px;margin-top:2.85714rem}.mt50{margin-top:50px;margin-top:3.57143rem}.mt60{margin-top:60px;margin-top:4.28571rem}.mt70{margin-top:70px;margin-top:5rem}.mt80{margin-top:80px;margin-top:5.71429rem}.mr10{margin-right:10px;margin-right:0.71429rem}.mr20{margin-right:20px;margin-right:1.42857rem}.mr30{margin-right:30px;margin-right:2.14286rem}.mr40{margin-right:40px;margin-right:2.85714rem}.mr50{margin-right:50px;margin-right:3.57143rem}.mr60{margin-right:60px;margin-right:4.28571rem}.mr70{margin-right:70px;margin-right:5rem}.mr80{margin-right:80px;margin-right:5.71429rem}.mb10{margin-bottom:10px;margin-bottom:0.71429rem}.mb20{margin-bottom:20px;margin-bottom:1.42857rem}.mb30{margin-bottom:30px;margin-bottom:2.14286rem}.mb40{margin-bottom:40px;margin-bottom:2.85714rem}.mb50{margin-bottom:50px;margin-bottom:3.57143rem}.mb60{margin-bottom:60px;margin-bottom:4.28571rem}.mb70{margin-bottom:70px;margin-bottom:5rem}.mb80{margin-bottom:80px;margin-bottom:5.71429rem}.ml10{margin-left:10px;margin-left:0.71429rem}.ml20{margin-left:20px;margin-left:1.42857rem}.ml30{margin-left:30px;margin-left:2.14286rem}.ml40{margin-left:40px;margin-left:2.85714rem}.ml50{margin-left:50px;margin-left:3.57143rem}.ml60{margin-left:60px;margin-left:4.28571rem}.ml70{margin-left:70px;margin-left:5rem}.ml80{margin-left:80px;margin-left:5.71429rem}.pos_rel{position:relative}.pos_abs{position:absolute}.fill_width{width:100% !important}@-webkit-keyframes spin{100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes spin{100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.ui-widget-content{color:#6f7e95;border-color:#f4f4f4}.ui-widget.ui-widget-content,.ui-timepicker-table.ui-widget-content{background:#fff;box-shadow:0 0 10px 0 rgba(0,0,0,0.5);box-shadow:0 0 0.71429rem 0 rgba(0,0,0,0.5)}.ui-widget{font-family:inherit;font-size:inherit}.ui-widget-header{border:0;background:#59677e;color:#fff;font-weight:bold}.ui-widget-header a{color:#fff}.ui-state-default,.ui-widget-content .ui-state-default,.ui-widget-header .ui-state-default{border:1px solid #ecf2f6;border:0.07143rem solid #ecf2f6;background:#fff;font-weight:bold;color:#6f7e95;border-radius:3px;border-radius:0.21429rem}.ui-widget-header .ui-state-default{background:none;color:#fff;border:0}.ui-state-hover,.ui-widget-content .ui-state-hover,.ui-widget-header .ui-state-hover,.ui-state-focus,.ui-widget-content .ui-state-focus,.ui-widget-header .ui-state-focus{border:1px solid #639af5;border:0.07143rem solid #639af5;background:#639af5;font-weight:bold;color:#fff}.ui-state-active,.ui-widget-content .ui-state-active,.ui-widget-header .ui-state-active{border:1px solid #47bac1;border:0.07143rem solid #47bac1;background:#47bac1;font-weight:bold;color:#fff}.ui-state-highlight,.ui-widget-content .ui-state-highlight,.ui-widget-header .ui-state-highlight{border:1px solid #639af5;border:0.07143rem solid #639af5;background:#fff;color:#639af5}@media only screen and (max-width: 480px){.ui-dialog{left:10px !important;left:0.71429rem !important;right:10px !important;right:0.71429rem !important;width:auto !important}}.ui-dialog-buttonpane{background:#ecf2f6;margin:.5em -0.2em -0.2em -0.2em}.ui-dialog-buttonpane .ui-button{border:0 !important;outline:0}.ui-icon{font-family:'jet-icons';speak:none;font-style:normal;font-weight:normal;font-variant:normal;text-transform:none;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;display:inline-block;font-size:16px;font-size:1.14286rem;font-weight:bold;background:none !important;text-indent:0;overflow:visible}.ui-icon-circle-triangle-e:before{content:""}.ui-icon-circle-triangle-w:before{content:""}.ui-icon-closethick:before{content:""}.ui-widget-overlay{background:#000;opacity:0.5;filter:Alpha(Opacity=50)}.ui-tooltip{background:#000 !important;color:#fff;border:0;box-shadow:none !important;opacity:0.8;font-size:13px;font-size:0.92857rem;pointer-events:none}.ui-datepicker table,.ui-timepicker table{margin:0 0 .4em;background:transparent;border-radius:0;box-shadow:none}.ui-datepicker th,.ui-timepicker th{background:inherit;color:inherit;text-transform:inherit}.ui-datepicker tbody tr,.ui-timepicker tbody tr{border-bottom:inherit}.ui-datepicker table{margin:0 0 .4em}.ui-timepicker-table table{margin:.15em 0 0} 2 | 3 | /*# sourceMappingURL=jquery-ui.theme.css.map */ 4 | -------------------------------------------------------------------------------- /src/api/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.6 on 2023-02-20 08:46 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | dependencies = [] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="NewsSite", 15 | fields=[ 16 | ( 17 | "id", 18 | models.BigAutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ("name", models.CharField(max_length=250)), 26 | ], 27 | ), 28 | migrations.CreateModel( 29 | name="Provider", 30 | fields=[ 31 | ( 32 | "id", 33 | models.BigAutoField( 34 | auto_created=True, 35 | primary_key=True, 36 | serialize=False, 37 | verbose_name="ID", 38 | ), 39 | ), 40 | ("name", models.CharField(max_length=250)), 41 | ], 42 | ), 43 | migrations.CreateModel( 44 | name="Report", 45 | fields=[ 46 | ( 47 | "id", 48 | models.BigAutoField( 49 | auto_created=True, 50 | primary_key=True, 51 | serialize=False, 52 | verbose_name="ID", 53 | ), 54 | ), 55 | ("title", models.CharField(max_length=250)), 56 | ("url", models.URLField()), 57 | ("image_url", models.URLField()), 58 | ("summary", models.TextField(blank=True)), 59 | ("published_at", models.DateTimeField()), 60 | ("updated_at", models.DateTimeField()), 61 | ( 62 | "news_site", 63 | models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="api.newssite"), 64 | ), 65 | ], 66 | options={ 67 | "ordering": ["-published_at"], 68 | }, 69 | ), 70 | migrations.CreateModel( 71 | name="Launch", 72 | fields=[ 73 | ( 74 | "id", 75 | models.BigAutoField( 76 | auto_created=True, 77 | primary_key=True, 78 | serialize=False, 79 | verbose_name="ID", 80 | ), 81 | ), 82 | ("launch_id", models.UUIDField(unique=True)), 83 | ("name", models.CharField(max_length=250)), 84 | ( 85 | "provider", 86 | models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="api.provider"), 87 | ), 88 | ], 89 | options={ 90 | "verbose_name_plural": "Launches", 91 | }, 92 | ), 93 | migrations.CreateModel( 94 | name="Event", 95 | fields=[ 96 | ( 97 | "id", 98 | models.BigAutoField( 99 | auto_created=True, 100 | primary_key=True, 101 | serialize=False, 102 | verbose_name="ID", 103 | ), 104 | ), 105 | ("event_id", models.IntegerField()), 106 | ("name", models.CharField(max_length=250)), 107 | ( 108 | "provider", 109 | models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="api.provider"), 110 | ), 111 | ], 112 | ), 113 | migrations.CreateModel( 114 | name="Blog", 115 | fields=[ 116 | ( 117 | "id", 118 | models.BigAutoField( 119 | auto_created=True, 120 | primary_key=True, 121 | serialize=False, 122 | verbose_name="ID", 123 | ), 124 | ), 125 | ("title", models.CharField(max_length=250)), 126 | ("url", models.URLField(unique=True)), 127 | ("image_url", models.URLField(max_length=500)), 128 | ("summary", models.TextField()), 129 | ("published_at", models.DateTimeField()), 130 | ("updated_at", models.DateTimeField()), 131 | ("featured", models.BooleanField(default=False)), 132 | ("events", models.ManyToManyField(blank=True, to="api.event")), 133 | ("launches", models.ManyToManyField(blank=True, to="api.launch")), 134 | ( 135 | "news_site", 136 | models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="api.newssite"), 137 | ), 138 | ], 139 | options={ 140 | "ordering": ["-published_at"], 141 | "abstract": False, 142 | }, 143 | ), 144 | migrations.CreateModel( 145 | name="Article", 146 | fields=[ 147 | ( 148 | "id", 149 | models.BigAutoField( 150 | auto_created=True, 151 | primary_key=True, 152 | serialize=False, 153 | verbose_name="ID", 154 | ), 155 | ), 156 | ("title", models.CharField(max_length=250)), 157 | ("url", models.URLField(unique=True)), 158 | ("image_url", models.URLField(max_length=500)), 159 | ("summary", models.TextField()), 160 | ("published_at", models.DateTimeField()), 161 | ("updated_at", models.DateTimeField()), 162 | ("featured", models.BooleanField(default=False)), 163 | ("events", models.ManyToManyField(blank=True, to="api.event")), 164 | ("launches", models.ManyToManyField(blank=True, to="api.launch")), 165 | ( 166 | "news_site", 167 | models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="api.newssite"), 168 | ), 169 | ], 170 | options={ 171 | "ordering": ["-published_at"], 172 | "abstract": False, 173 | }, 174 | ), 175 | ] 176 | -------------------------------------------------------------------------------- /src/api/tests/test_reports_endpoint.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.test.client import Client 3 | 4 | from api.models import NewsSite, Report 5 | 6 | 7 | @pytest.mark.django_db 8 | class TestReportsEndpoint: 9 | def test_get_reports(self, client: Client, reports: list[Report]) -> None: 10 | response = client.get("/v4/reports/") 11 | assert response.status_code == 200 12 | 13 | data = response.json() 14 | assert len(data["results"]) == 10 15 | 16 | @pytest.mark.skip("Keeps failing?") 17 | def test_get_single_report(self, client: Client, reports: list[Report]) -> None: 18 | response = client.get(path="/v4/reports/1/") 19 | 20 | data = response.json() 21 | assert data["id"] == 1 22 | assert data["title"] == "Report 0" 23 | 24 | def test_limit_reports(self, client: Client, reports: list[Report]) -> None: 25 | response = client.get("/v4/reports/?limit=5") 26 | assert response.status_code == 200 27 | 28 | data = response.json() 29 | assert len(data["results"]) == 5 30 | 31 | def test_get_report_by_news_site(self, client: Client, reports: list[Report], news_sites: list[NewsSite]) -> None: 32 | filtered_reports = [report for report in reports if report.news_site.name == news_sites[0].name] 33 | 34 | response = client.get(f"/v4/reports/?news_site={news_sites[0].name}") 35 | assert response.status_code == 200 36 | 37 | data = response.json() 38 | assert data["results"][0]["title"] == filtered_reports[0].title 39 | assert data["results"][0]["news_site"] == news_sites[0].name 40 | assert len(data["results"]) == len(filtered_reports) 41 | 42 | def test_get_reports_by_multiple_news_sites( 43 | self, client: Client, reports: list[Report], news_sites: list[NewsSite] 44 | ) -> None: 45 | filtered_reports = [ 46 | report for report in reports if report.news_site.name in [news_sites[0].name, news_sites[1].name] 47 | ] 48 | 49 | response = client.get(f"/v4/reports/?news_site={news_sites[0].name},{news_sites[1].name}&limit=100") 50 | assert response.status_code == 200 51 | 52 | data = response.json() 53 | assert len(data["results"]) == len(filtered_reports) 54 | assert all(report["title"] in [report.title for report in filtered_reports] for report in data["results"]) 55 | 56 | def test_get_reports_with_offset(self, client: Client, reports: list[Report]) -> None: 57 | response = client.get("/v4/reports/?offset=5") 58 | assert response.status_code == 200 59 | 60 | data = response.json() 61 | 62 | assert len(data["results"]) == 10 63 | 64 | def test_get_reports_with_limit(self, client: Client, reports: list[Report]) -> None: 65 | response = client.get("/v4/reports/?limit=3") 66 | assert response.status_code == 200 67 | 68 | data = response.json() 69 | 70 | assert len(data["results"]) == 3 71 | 72 | def test_get_reports_with_ordering(self, client: Client, reports: list[Report]) -> None: 73 | sorted_data = sorted(reports, key=lambda report: report.published_at, reverse=True) 74 | 75 | response = client.get("/v4/reports/?ordering=-published_at") 76 | assert response.status_code == 200 77 | 78 | data = response.json() 79 | 80 | assert data["results"][0]["title"] == sorted_data[0].title 81 | 82 | def test_get_reports_published_at_greater_then(self, client: Client, reports: list[Report]) -> None: 83 | reports_in_the_future = list( 84 | filter( 85 | lambda report: report.title.startswith("Report in the future"), 86 | reports, 87 | ) 88 | ) 89 | 90 | response = client.get("/v4/reports/?published_at_gt=2040-10-01") 91 | assert response.status_code == 200 92 | 93 | data = response.json() 94 | 95 | assert all(report["title"] in [report.title for report in reports_in_the_future] for report in data["results"]) 96 | assert len(data["results"]) == 2 97 | 98 | def test_get_reports_published_at_lower_then(self, client: Client, reports: list[Report]) -> None: 99 | reports_in_the_past = list( 100 | filter( 101 | lambda report: report.title.startswith("Report in the past"), 102 | reports, 103 | ) 104 | ) 105 | 106 | response = client.get("/v4/reports/?published_at_lt=2001-01-01") 107 | assert response.status_code == 200 108 | 109 | data = response.json() 110 | 111 | assert all(report["title"] in [report.title for report in reports_in_the_past] for report in data["results"]) 112 | assert len(data["results"]) == 2 113 | 114 | def test_get_report_search_reports(self, client: Client, reports: list[Report]) -> None: 115 | response = client.get("/v4/reports/?search=title") 116 | assert response.status_code == 200 117 | 118 | data = response.json() 119 | 120 | assert data["results"][0]["title"] == "Report with specific title" 121 | assert len(data["results"]) == 1 122 | 123 | def test_get_reports_search_summary(self, client: Client, reports: list[Report]) -> None: 124 | response = client.get("/v4/reports/?search=title") 125 | assert response.status_code == 200 126 | 127 | data = response.json() 128 | 129 | assert data["results"][0]["summary"] == "Description of an report with a specific title" 130 | assert len(data["results"]) == 1 131 | 132 | def test_get_reports_title_contains(self, client: Client, reports: list[Report]) -> None: 133 | response = client.get("/v4/reports/?title_contains=Report with specific") 134 | assert response.status_code == 200 135 | 136 | data = response.json() 137 | 138 | assert data["results"][0]["title"] == "Report with specific title" 139 | assert len(data["results"]) == 1 140 | 141 | def test_get_reports_title_contains_one(self, client: Client, reports: list[Report]) -> None: 142 | response = client.get("/v4/reports/?title_contains_one=SpaceX, specific") 143 | assert response.status_code == 200 144 | 145 | data = response.json() 146 | 147 | assert data["results"][0]["title"] == "Report with specific title" 148 | assert len(data["results"]) == 2 149 | 150 | def test_get_reports_title_contains_all(self, client: Client, reports: list[Report]) -> None: 151 | response = client.get("/v4/reports/?title_contains_all=specific, with, title") 152 | assert response.status_code == 200 153 | 154 | data = response.json() 155 | 156 | assert data["results"][0]["title"] == "Report with specific title" 157 | assert len(data["results"]) == 1 158 | 159 | def test_get_reports_summary_contains(self, client: Client, reports: list[Report]) -> None: 160 | response = client.get("/v4/reports/?summary_contains=specific") 161 | assert response.status_code == 200 162 | 163 | data = response.json() 164 | 165 | assert data["results"][0]["title"] == "Report with specific title" 166 | assert len(data["results"]) == 1 167 | 168 | def test_get_reports_summary_contains_one(self, client: Client, reports: list[Report]) -> None: 169 | response = client.get("/v4/reports/?summary_contains_one=SpaceX, specific") 170 | assert response.status_code == 200 171 | 172 | data = response.json() 173 | 174 | assert data["results"][0]["title"] == "Report with specific title" 175 | assert len(data["results"]) == 2 176 | 177 | def test_get_reports_summary_contains_all(self, client: Client, reports: list[Report]) -> None: 178 | response = client.get("/v4/reports/?summary_contains_all=specific, with, title") 179 | assert response.status_code == 200 180 | 181 | data = response.json() 182 | 183 | assert data["results"][0]["title"] == "Report with specific title" 184 | assert len(data["results"]) == 1 185 | 186 | def test_soft_deleted(self, client: Client, reports: list[Report]) -> None: 187 | response = client.get("/v4/reports/?title_contains=Deleted") 188 | assert response.status_code == 200 189 | 190 | data = response.json() 191 | 192 | assert len(data["results"]) == 0 193 | -------------------------------------------------------------------------------- /src/api/views/filters.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from django.db.models import Q, QuerySet 4 | from django_filters import ( 5 | BaseInFilter, 6 | BooleanFilter, 7 | CharFilter, 8 | FilterSet, 9 | IsoDateTimeFilter, 10 | NumberFilter, 11 | OrderingFilter, 12 | UUIDFilter, 13 | ) 14 | from rest_framework import filters 15 | 16 | 17 | class UUIDInFilter(BaseInFilter, UUIDFilter): 18 | def __init__(self, *args: Any, **kwargs: Any): 19 | super().__init__(*args, **kwargs) 20 | 21 | 22 | class NumberInFilter(BaseInFilter, NumberFilter): 23 | def __init__(self, *args: Any, **kwargs: Any): 24 | super().__init__(*args, **kwargs) 25 | 26 | 27 | class CharInFilter(CharFilter): 28 | def __init__(self, **kwargs: Any): 29 | self.field_name = kwargs.pop("field_name") 30 | super().__init__( 31 | field_name=self.field_name, 32 | method=self.filter_keywords, 33 | label=f"Search for documents with a {self.field_name} " 34 | f"present in a list of comma-separated values. Case " 35 | f"insensitive.", 36 | ) 37 | 38 | def filter_keywords(self, queryset: QuerySet[Any], name: str, value: str) -> Any: 39 | words = value.split(",") 40 | q = Q() 41 | for word in words: 42 | q |= Q(**{f"{name}__iexact": word.strip()}) 43 | return queryset.filter(q) 44 | 45 | 46 | class CharNotInFilter(CharFilter): 47 | def __init__(self, **kwargs: Any): 48 | self.field_name = kwargs.pop("field_name") 49 | super().__init__( 50 | field_name=self.field_name, 51 | method=self.filter_keywords, 52 | label=f"Search for documents with a {self.field_name} " 53 | f"not present in a list of comma-separated values. " 54 | f"Case insensitive.", 55 | ) 56 | 57 | def filter_keywords(self, queryset: QuerySet[Any], name: str, value: str) -> Any: 58 | words = value.split(",") 59 | q = Q() 60 | for word in words: 61 | q |= Q(**{f"{name}__iexact": word.strip()}) 62 | return queryset.exclude(q) 63 | 64 | 65 | class ContainsOneFilter(CharFilter): 66 | def __init__(self, **kwargs: Any): 67 | self.field_name = kwargs.pop("field_name") 68 | super().__init__( 69 | field_name=self.field_name, 70 | method=self.filter_keywords, 71 | label=f"Search for documents with a {self.field_name} " 72 | f"containing at least one keyword from " 73 | f"comma-separated values.", 74 | ) 75 | 76 | def filter_keywords(self, queryset: QuerySet[Any], name: str, value: str) -> Any: 77 | words = value.split(",") 78 | q = Q() 79 | for word in words: 80 | q |= Q(**{f"{name}__icontains": word.strip()}) 81 | return queryset.filter(q) 82 | 83 | 84 | class ContainsAllFilter(CharFilter): 85 | def __init__(self, **kwargs: Any): 86 | self.field_name = kwargs.pop("field_name") 87 | super().__init__( 88 | field_name=self.field_name, 89 | method=self.filter_keywords, 90 | label=f"Search for documents with a {self.field_name} containing all keywords from comma-separated values.", 91 | ) 92 | 93 | def filter_keywords(self, queryset: QuerySet[Any], name: str, value: str) -> Any: 94 | words = value.split(",") 95 | q = Q() 96 | for word in words: 97 | q &= Q(**{f"{name}__icontains": word.strip()}) 98 | return queryset.filter(q) 99 | 100 | 101 | class OrderingFilterWithLabel(OrderingFilter): 102 | def __init__(self, **kwargs: Any) -> None: 103 | self.fields = kwargs.get("fields") 104 | 105 | if not self.fields: 106 | raise AssertionError("The 'fields' argument is required.") 107 | 108 | # Itterate over the tupple, generating a string of all first elements, including negative ones. 109 | fields = ", ".join([f"{field[0]}, -{field[0]}" for field in self.fields]) 110 | 111 | super().__init__( 112 | label=f"Order the result on `{fields}`.", 113 | **kwargs, 114 | ) 115 | 116 | 117 | class BaseFilter(FilterSet): 118 | title_contains = CharFilter( 119 | field_name="title", 120 | lookup_expr="icontains", 121 | label="Search for all documents with a specific phrase in the title.", 122 | ) 123 | title_contains_one = ContainsOneFilter(field_name="title") 124 | title_contains_all = ContainsAllFilter(field_name="title") 125 | summary_contains_one = ContainsOneFilter(field_name="summary") 126 | summary_contains_all = ContainsAllFilter(field_name="summary") 127 | news_site = CharInFilter(field_name="news_site__name") 128 | news_site_exclude = CharNotInFilter(field_name="news_site__name") 129 | summary_contains = CharFilter( 130 | field_name="summary", 131 | lookup_expr="icontains", 132 | label="Search for all documents with a specific phrase in the summary.", 133 | ) 134 | published_at_gte = IsoDateTimeFilter( 135 | field_name="published_at", 136 | lookup_expr="gte", 137 | label="Get all documents published after a given ISO8601 timestamp (included).", 138 | ) 139 | published_at_lte = IsoDateTimeFilter( 140 | field_name="published_at", 141 | lookup_expr="lte", 142 | label="Get all documents published before a given ISO8601 timestamp (included).", 143 | ) 144 | published_at_gt = IsoDateTimeFilter( 145 | field_name="published_at", 146 | lookup_expr="gt", 147 | label="Get all documents published after a given ISO8601 timestamp (excluded).", 148 | ) 149 | published_at_lt = IsoDateTimeFilter( 150 | field_name="published_at", 151 | lookup_expr="lt", 152 | label="Get all documents published before a given ISO8601 timestamp (excluded).", 153 | ) 154 | updated_at_gte = IsoDateTimeFilter( 155 | field_name="updated_at", 156 | lookup_expr="gte", 157 | label="Get all documents updated after a given ISO8601 timestamp (included).", 158 | ) 159 | updated_at_lte = IsoDateTimeFilter( 160 | field_name="updated_at", 161 | lookup_expr="lte", 162 | label="Get all documents updated before a given ISO8601 timestamp (included).", 163 | ) 164 | updated_at_gt = IsoDateTimeFilter( 165 | field_name="updated_at", 166 | lookup_expr="gt", 167 | label="Get all documents updated after a given ISO8601 timestamp (excluded).", 168 | ) 169 | updated_at_lt = IsoDateTimeFilter( 170 | field_name="updated_at", 171 | lookup_expr="lt", 172 | label="Get all documents updated before a given ISO8601 timestamp (excluded).", 173 | ) 174 | 175 | ordering = OrderingFilterWithLabel( 176 | fields=( 177 | ( 178 | "published_at", 179 | "published_at", 180 | ), 181 | ("updated_at", "updated_at"), 182 | ), 183 | ) 184 | 185 | 186 | class DocsFilter(BaseFilter): 187 | launch = UUIDInFilter( 188 | field_name="launches__launch_id", 189 | lookup_expr="in", 190 | help_text="Search for all documents related to a specific launch using its Launch Library 2 ID.", 191 | ) 192 | event = NumberInFilter( 193 | field_name="events__event_id", 194 | lookup_expr="in", 195 | help_text="Search for all documents related to a specific event using its Launch Library 2 ID.", 196 | ) 197 | has_launch = BooleanFilter( 198 | field_name="launches", 199 | lookup_expr="isnull", 200 | exclude=True, 201 | label="Get all documents that have a related launch.", 202 | ) 203 | has_event = BooleanFilter( 204 | field_name="events", 205 | lookup_expr="isnull", 206 | exclude=True, 207 | label="Get all documents that have a related event.", 208 | ) 209 | is_featured = BooleanFilter( 210 | field_name="featured", 211 | lookup_expr="exact", 212 | label="Get all documents that are featured.", 213 | ) 214 | 215 | 216 | class SearchFilter(filters.SearchFilter): 217 | search_description = "Search for documents with a specific phrase in the title or summary." 218 | -------------------------------------------------------------------------------- /.github/profile/assets/badge_snapi_website.svg: -------------------------------------------------------------------------------- 1 | WEBSITEWEBSITE 2 | -------------------------------------------------------------------------------- /.github/profile/assets/badge_snapi_doc.svg: -------------------------------------------------------------------------------- 1 | DOCUMENTATIONDOCUMENTATION 2 | -------------------------------------------------------------------------------- /src/snapy/settings.py: -------------------------------------------------------------------------------- 1 | """Django's settings for snapy project. 2 | 3 | Generated by 'django-admin startproject' using Django 4.1.4. 4 | 5 | For more information on this file, see 6 | https://docs.djangoproject.com/en/4.1/topics/settings/ 7 | 8 | For the full list of settings and their values, see 9 | https://docs.djangoproject.com/en/4.1/ref/settings/ 10 | """ 11 | 12 | import importlib.metadata 13 | from pathlib import Path 14 | 15 | import django_stubs_ext 16 | from environs import Env 17 | from snapy import __version__ 18 | 19 | env = Env() 20 | env.read_env() 21 | 22 | # Extensions for Django Stubs 23 | django_stubs_ext.monkeypatch() 24 | 25 | VERSION = __version__ 26 | 27 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 28 | BASE_DIR = Path(__file__).resolve().parent.parent 29 | 30 | # Quick-start development settings - unsuitable for production 31 | # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ 32 | 33 | # SECURITY WARNING: keep the secret key used in production secret! 34 | SECRET_KEY = env.str("SECRET_KEY") 35 | 36 | # Get the forwarded protocol from the proxy server 37 | SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") 38 | USE_X_FORWARDED_HOST = True 39 | 40 | # SECURITY WARNING: don't run with debug turned on in production! 41 | DEBUG = env.bool("DEBUG", False) 42 | 43 | 44 | if env.str("SENTRY_DSN", None): 45 | import sentry_sdk 46 | from sentry_sdk.integrations.django import DjangoIntegration 47 | 48 | sentry_sdk.init( 49 | dsn=env.str("SENTRY_DSN"), 50 | integrations=[DjangoIntegration()], 51 | traces_sample_rate=0.01, 52 | send_default_pii=True, 53 | enable_tracing=True, 54 | release=VERSION, 55 | environment=env.str("SENTRY_ENVIRONMENT"), 56 | ) 57 | 58 | ALLOWED_HOSTS = ["*"] 59 | CSRF_TRUSTED_ORIGINS: list["str"] = env.list("CSRF_TRUSTED_ORIGIN") 60 | 61 | # Application definition 62 | 63 | INSTALLED_APPS = [ 64 | "api", 65 | "importer", 66 | "jet.dashboard", 67 | "jet", 68 | "django.contrib.admin", 69 | "django.contrib.auth", 70 | "django.contrib.contenttypes", 71 | "django.contrib.sessions", 72 | "django.contrib.messages", 73 | "django.contrib.staticfiles", 74 | "rest_framework", 75 | "django_filters", 76 | "corsheaders", 77 | "drf_spectacular", 78 | "storages", 79 | "health_check", 80 | "health_check.cache", 81 | "health_check.db", 82 | "health_check.contrib.s3boto3_storage", 83 | "health_check.contrib.redis", 84 | "graphene_django", 85 | "cachalot" 86 | ] 87 | 88 | if DEBUG: 89 | INSTALLED_APPS.append("debug_toolbar") 90 | 91 | MIDDLEWARE = [ 92 | "django.middleware.cache.UpdateCacheMiddleware", 93 | "corsheaders.middleware.CorsMiddleware", 94 | "django.middleware.security.SecurityMiddleware", 95 | "django.contrib.sessions.middleware.SessionMiddleware", 96 | "django.middleware.common.CommonMiddleware", 97 | "django.middleware.csrf.CsrfViewMiddleware", 98 | "django.contrib.auth.middleware.AuthenticationMiddleware", 99 | "django.contrib.messages.middleware.MessageMiddleware", 100 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 101 | "django.middleware.cache.FetchFromCacheMiddleware", 102 | ] 103 | 104 | if DEBUG: 105 | MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware") 106 | 107 | X_FRAME_OPTIONS = 'SAMEORIGIN' 108 | 109 | ROOT_URLCONF = "snapy.urls" 110 | 111 | CORS_ORIGIN_ALLOW_ALL = True 112 | 113 | TEMPLATES = [ 114 | { 115 | "BACKEND": "django.template.backends.django.DjangoTemplates", 116 | "DIRS": [], 117 | "APP_DIRS": True, 118 | "OPTIONS": { 119 | "context_processors": [ 120 | "django.template.context_processors.debug", 121 | "django.template.context_processors.request", 122 | "django.contrib.auth.context_processors.auth", 123 | "django.contrib.messages.context_processors.messages", 124 | ], 125 | }, 126 | }, 127 | ] 128 | 129 | JET_DEFAULT_THEME = "dark" 130 | 131 | WSGI_APPLICATION = "snapy.wsgi.application" 132 | 133 | # Database 134 | # https://docs.djangoproject.com/en/4.1/ref/settings/#databases 135 | 136 | DATABASES = {"default": env.dj_db_url("DATABASE_URL")} 137 | 138 | # Password validation 139 | # https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators 140 | 141 | AUTH_PASSWORD_VALIDATORS = [ 142 | { 143 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 144 | }, 145 | { 146 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 147 | }, 148 | { 149 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 150 | }, 151 | { 152 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 153 | }, 154 | ] 155 | 156 | # Internationalization 157 | # https://docs.djangoproject.com/en/4.1/topics/i18n/ 158 | 159 | LANGUAGE_CODE = "en-us" 160 | 161 | TIME_ZONE = "UTC" 162 | 163 | USE_I18N = True 164 | 165 | USE_TZ = True 166 | 167 | # Static files (CSS, JavaScript, Images) 168 | # https://docs.djangoproject.com/en/4.1/howto/static-files/ 169 | STORAGES = { 170 | "staticfiles": { 171 | "BACKEND": "storages.backends.s3.S3Storage", 172 | "OPTIONS": { 173 | "access_key": env.str("AWS_ACCESS_KEY_ID"), 174 | "secret_key": env.str("AWS_SECRET_ACCESS_KEY"), 175 | "bucket_name": env.str("AWS_STORAGE_BUCKET_NAME"), 176 | "endpoint_url": env.str("AWS_S3_ENDPOINT_URL", "https://ams3.digitaloceanspaces.com"), 177 | "object_parameters": {"CacheControl": "max-age=86400"}, 178 | "default_acl": "public-read", 179 | "querystring_auth": False, 180 | "region_name": env.str("AWS_S3_REGION_NAME", "ams3"), 181 | "gzip": True, 182 | "location": "static", 183 | }, 184 | }, 185 | } 186 | 187 | STATICFILES_DIRS = [BASE_DIR.joinpath("static")] 188 | STATIC_URL = "static/" 189 | 190 | # Default primary key field type 191 | # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field 192 | 193 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 194 | 195 | REST_FRAMEWORK = { 196 | "DEFAULT_PAGINATION_CLASS": "api.utils.pagination.CustomLimitOffsetPagination", 197 | "PAGE_SIZE": 10, 198 | "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], 199 | "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", 200 | "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.NamespaceVersioning", 201 | "NUM_PROXIES": env.int("NUM_PROXIES", 0), 202 | } 203 | 204 | 205 | if env.bool("ENABLE_THROTTLE", False): 206 | REST_FRAMEWORK['DEFAULT_THROTTLE_CLASSES'] = [ 207 | 'rest_framework.throttling.AnonRateThrottle' 208 | ] 209 | 210 | REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'] = { 211 | 'anon': '5/second', 212 | } 213 | 214 | 215 | SPECTACULAR_SETTINGS = { 216 | "TITLE": "Spaceflight News API", 217 | "DESCRIPTION": "The Spaceflight News API (SNAPI) is a product by [The Space Devs](https://thespacedevs.com) (TSD). It's the most complete and up-to-date spaceflight news API currently available." 218 | "\n\nWhile this API is **free to use**, we do encourage developers to support us through [Patreon](https://www.patreon.com/TheSpaceDevs) to keep the API up and running." 219 | "\n\n ### GraphQL" 220 | "\n\n The Spaceflight News API also has GraphQL support available! You can find the GraphiQl IDE [here](https://api.spaceflightnewsapi.net/v4/graphql/)." 221 | "\n\n ### FAQs & Tutorials" 222 | "\n\n - [GitHub repository](https://github.com/TheSpaceDevs/Tutorials/): contains FAQs and tutorials for TSD APIs" 223 | "\n\n - [TSD FAQ](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md): TSD-specific FAQ (e.g. history, network, funding, etc.)" 224 | "\n\n - [SNAPI FAQ](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_SNAPI.md): SNAPI-specific FAQ" 225 | "\n\n ### Feedback & Support" 226 | "\n\n If you need any help with SNAPI, you can ask in the " 227 | "[`💬feedback-and-help`](https://discord.com/channels/676725644444565514/1019976345884827750) forum of the TSD " 228 | "[Discord server](https://discord.gg/p7ntkNA) or email [derk@spaceflightnewsapi.net](mailto:derk@spaceflightnewsapi.net).", 229 | "VERSION": VERSION, 230 | "SERVE_INCLUDE_SCHEMA": False, 231 | "SCHEMA_PATH_PREFIX": "/v4", 232 | } 233 | 234 | # LL Settings 235 | LL_URL = env.str("LL_URL", "https://lldev.thespacedevs.com/2.2.0") 236 | LL_TOKEN = env.str("LL_TOKEN", "") 237 | 238 | HEALTH_CHECK = { 239 | "SUBSETS": { 240 | "startup-probe": ["MigrationsHealthCheck", "DatabaseBackend", "CacheBackend"], 241 | "liveness-probe": ["DatabaseBackend"], 242 | }, 243 | } 244 | 245 | GRAPHENE = { 246 | "SCHEMA": "snapy.schema.schema", 247 | } 248 | 249 | if env.bool("ENABLE_CACHE", False): 250 | CACHES = { 251 | "default": { 252 | "BACKEND": "django_redis.cache.RedisCache", 253 | "LOCATION": env.str("REDIS_URL", "redis://localhost:6379"), 254 | "OPTIONS": { 255 | "CLIENT_CLASS": "django_redis.client.DefaultClient", 256 | } 257 | } 258 | } 259 | CACHALOT_ENABLED = env.bool("CACHALOT_ENABLED", False) 260 | CACHALOT_TIMEOUT = env.int("CACHALOT_TIMEOUT", 300) 261 | 262 | DEBUG_TOOLBAR_PANELS = [ 263 | "debug_toolbar.panels.versions.VersionsPanel", 264 | "debug_toolbar.panels.timer.TimerPanel", 265 | "debug_toolbar.panels.settings.SettingsPanel", 266 | "debug_toolbar.panels.headers.HeadersPanel", 267 | "debug_toolbar.panels.request.RequestPanel", 268 | "debug_toolbar.panels.sql.SQLPanel", 269 | "debug_toolbar.panels.staticfiles.StaticFilesPanel", 270 | "debug_toolbar.panels.templates.TemplatesPanel", 271 | "debug_toolbar.panels.cache.CachePanel", 272 | "debug_toolbar.panels.signals.SignalsPanel", 273 | "debug_toolbar.panels.logging.LoggingPanel", 274 | "cachalot.panels.CachalotPanel" 275 | ] 276 | INTERNAL_IPS = [ 277 | "127.0.0.1", 278 | ] 279 | -------------------------------------------------------------------------------- /src/api/admin.py: -------------------------------------------------------------------------------- 1 | """Custom admin views for the Spaceflight News API.""" 2 | 3 | from django import forms 4 | from django.contrib import admin 5 | from django.contrib.admin.templatetags.admin_urls import admin_urlname 6 | from django.db.models import QuerySet 7 | from django.http import HttpRequest, HttpResponse 8 | from django.shortcuts import resolve_url 9 | from django.utils.html import format_html 10 | from django.utils.safestring import SafeString 11 | 12 | # ignore the type error as it seems there's no package for it 13 | from jet.filters import RelatedFieldAjaxListFilter # type: ignore 14 | 15 | from api.models import Article, Author, Blog, Event, Launch, NewsSite, Provider, Report, Socials 16 | from api.models.abc import NewsItem 17 | 18 | 19 | class ArticleForm(forms.ModelForm[NewsItem]): 20 | title = forms.CharField(widget=forms.TextInput(attrs={"size": 70}), required=True) 21 | 22 | 23 | # Register your models here. 24 | # Models that need customization 25 | @admin.register(Article) 26 | @admin.register(Blog) 27 | class ArticleAdmin(admin.ModelAdmin[NewsItem]): 28 | """Admin view for articles and blogs.""" 29 | 30 | list_per_page = 30 31 | form = ArticleForm 32 | actions = ["mark_as_audited", "unmark_as_audited"] 33 | list_display = ( 34 | "title", 35 | "thumbnail", 36 | "published_at_formatted", 37 | "news_site_formatted", 38 | "authors_formatted", 39 | "assigned_launches", 40 | "assigned_events", 41 | "featured_formatted", 42 | "is_deleted_formatted", 43 | "audited_formatted", 44 | ) 45 | list_filter = ( 46 | ("launches", RelatedFieldAjaxListFilter), 47 | ("events", RelatedFieldAjaxListFilter), 48 | ("authors", RelatedFieldAjaxListFilter), 49 | "news_site", 50 | "published_at", 51 | "featured", 52 | "is_deleted", 53 | "audited", 54 | ) 55 | search_fields = ["title"] 56 | ordering = ("-published_at",) 57 | readonly_fields = [ 58 | "image_tag", 59 | ] 60 | fields = [ 61 | "title", 62 | "authors", 63 | "url", 64 | "image_url", 65 | "news_site", 66 | "summary", 67 | "published_at", 68 | "launches", 69 | "events", 70 | "image_tag", 71 | "audited", 72 | "featured", 73 | "is_deleted", 74 | ] 75 | 76 | @admin.action(description="Mark selected articles as audited") 77 | def mark_as_audited(self, request: HttpRequest, queryset: QuerySet[NewsItem]) -> None: 78 | queryset.update(audited=True) 79 | 80 | @admin.action(description="Unmark selected articles as audited") 81 | def unmark_as_audited(self, request: HttpRequest, queryset: QuerySet[NewsItem]) -> None: 82 | queryset.update(audited=False) 83 | 84 | def get_queryset(self, request: HttpRequest) -> QuerySet[NewsItem]: 85 | """Return the queryset with related fields prefetched.""" 86 | qs = super().get_queryset(request).select_related("news_site").prefetch_related("launches", "events") 87 | return qs 88 | 89 | @staticmethod 90 | def thumbnail(obj: NewsItem) -> SafeString: 91 | """Returns the publication image as an interactive thumbnail.""" 92 | return format_html('', obj.image_url) 93 | 94 | @staticmethod 95 | @admin.display( 96 | ordering="-published_at", 97 | description="Published at", 98 | ) 99 | def published_at_formatted(obj: NewsItem) -> str: 100 | """Returns the publication datetime as a formatted string.""" 101 | return obj.published_at.strftime("%B %d, %Y – %H:%M") 102 | 103 | @staticmethod 104 | @admin.display( 105 | ordering="news_site", 106 | description="News site", 107 | ) 108 | def news_site_formatted(obj: NewsItem) -> SafeString: 109 | """Returns the news site as a hyperlink to the article page.""" 110 | return format_html('{}', obj.url, obj.news_site) 111 | 112 | @staticmethod 113 | @admin.display( 114 | description="Authors", 115 | ) 116 | def authors_formatted(obj: NewsItem) -> SafeString: 117 | """Returns the authors as a list of hyperlinks.""" 118 | authors_list = [ 119 | f'{author.name}' 120 | for author in obj.authors.all() 121 | ] 122 | string = [authors_list[i : i + 3] for i in range(0, len(authors_list), 3)] # Group into lines of up to 3 authors 123 | split_string = "
".join([", ".join(line) for line in string]) # Format each line and join with
124 | return format_html(split_string) 125 | 126 | @staticmethod 127 | @admin.display( 128 | description=format_html( 129 | '
{}
', 130 | "LL2 Launches", 131 | "L", 132 | ), 133 | ) 134 | def assigned_launches(obj: NewsItem) -> SafeString: 135 | """Returns the number of launches assigned to the article.""" 136 | count = obj.launches.count() 137 | if count == 0: 138 | return format_html("") 139 | return format_html( 140 | '
{}
', 141 | "\n".join([launch.name for launch in obj.launches.all()]), 142 | obj.launches.count(), 143 | ) 144 | 145 | @staticmethod 146 | @admin.display( 147 | description=format_html( 148 | '
{}
', 149 | "LL2 Events", 150 | "E", 151 | ) 152 | ) 153 | def assigned_events(obj: NewsItem) -> SafeString: 154 | """Returns the number of events assigned to the article.""" 155 | count = obj.events.count() 156 | if count == 0: 157 | return format_html("") 158 | return format_html( 159 | '
{}
', 160 | "\n".join([event.name for event in obj.events.all()]), 161 | obj.events.count(), 162 | ) 163 | 164 | @staticmethod 165 | @admin.display( 166 | boolean=True, 167 | ordering="featured", 168 | description=format_html( 169 | '
{}
', 170 | "Featured", 171 | "F", 172 | ), 173 | ) 174 | def featured_formatted(obj: NewsItem) -> bool: 175 | """Returns whether the article is featured.""" 176 | return obj.featured 177 | 178 | @staticmethod 179 | @admin.display( 180 | boolean=True, 181 | ordering="is_deleted", 182 | description=format_html( 183 | '
{}
', 184 | "Deleted", 185 | "D", 186 | ), 187 | ) 188 | def is_deleted_formatted(obj: NewsItem) -> bool: 189 | """Returns whether the article is hidden from the API response.""" 190 | return obj.is_deleted 191 | 192 | @staticmethod 193 | @admin.display( 194 | boolean=True, 195 | ordering="audited", 196 | description=format_html( 197 | '
{}
', 198 | "Audited", 199 | "A", 200 | ), 201 | ) 202 | def audited_formatted(obj: NewsItem) -> bool: 203 | """Returns whether the article has been audited.""" 204 | return obj.audited 205 | 206 | @staticmethod 207 | @admin.display(description="Image") 208 | def image_tag(obj: NewsItem) -> SafeString: 209 | """Returns the image of the article.""" 210 | return format_html('', obj.image_url) 211 | 212 | def changelist_view(self, request: HttpRequest, extra_context: dict[str, str] | None = None) -> HttpResponse: 213 | """Customize the title of the article admin view.""" 214 | extra_context = {"title": "News"} 215 | return super().changelist_view(request, extra_context) 216 | 217 | def save_model(self, request: HttpRequest, obj: NewsItem, form: forms.ModelForm[NewsItem], change: bool) -> None: 218 | if change: 219 | # Ignore the type error as obj will be an instance of Article of Blog 220 | old_object: NewsItem = type(obj).objects.get(pk=obj.pk) # type: ignore 221 | 222 | # If the audited field is not the same as the old object, update it 223 | # Otherwise, set it to True by default 224 | if old_object.audited != obj.audited: 225 | obj.audited = form.cleaned_data["audited"] 226 | else: 227 | obj.audited = True 228 | else: 229 | obj.audited = True 230 | super().save_model(request, obj, form, change) 231 | 232 | 233 | @admin.register(Report) 234 | class ReportAdmin(admin.ModelAdmin[Report]): 235 | """Custom admin view for reports.""" 236 | 237 | list_display = ("title", "news_site", "published_at", "is_deleted") 238 | search_fields = ["title"] 239 | ordering = ("-published_at",) 240 | 241 | def get_queryset(self, request: HttpRequest) -> QuerySet[Report]: 242 | """Return the queryset with related fields prefetched.""" 243 | return super().get_queryset(request).select_related("news_site") 244 | 245 | 246 | @admin.register(NewsSite) 247 | class NewsSiteAdmin(admin.ModelAdmin[NewsSite]): 248 | list_display = ("name", "id") 249 | 250 | def changelist_view(self, request: HttpRequest, extra_context: dict[str, str] | None = None) -> HttpResponse: 251 | """Customize the title of the news site admin view.""" 252 | extra_context = {"title": "News Sites"} 253 | return super().changelist_view(request, extra_context) 254 | 255 | 256 | # Models that can be registered as is 257 | @admin.register(Event) 258 | class EventAdmin(admin.ModelAdmin[Event]): 259 | list_display = ("name",) 260 | search_fields = ["name", "event_id"] 261 | 262 | 263 | @admin.register(Launch) 264 | class LaunchAdmin(admin.ModelAdmin[Launch]): 265 | list_display = ("name",) 266 | search_fields = ["name", "launch_id"] 267 | 268 | 269 | admin.site.register(Provider) 270 | admin.site.register(Author) 271 | admin.site.register(Socials) 272 | 273 | # Other customizations 274 | admin.site.site_title = "SNAPI Admin" 275 | admin.site.site_header = "SNAPI Admin" 276 | admin.site.index_title = "Home" 277 | -------------------------------------------------------------------------------- /src/api/utils/types/launch_response.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import Enum 3 | from typing import Any 4 | from uuid import UUID 5 | 6 | 7 | class LaunchServiceProviderName(Enum): 8 | ARMY_BALLISTIC_MISSILE_AGENCY = "Army Ballistic Missile Agency" 9 | SOVIET_SPACE_PROGRAM = "Soviet Space Program" 10 | US_NAVY = "US Navy" 11 | 12 | 13 | class LaunchServiceProviderType(Enum): 14 | GOVERNMENT = "Government" 15 | 16 | 17 | class LaunchServiceProvider: 18 | id: int 19 | url: str 20 | name: LaunchServiceProviderName 21 | type: LaunchServiceProviderType 22 | 23 | def __init__( 24 | self, 25 | id: int, 26 | url: str, 27 | name: LaunchServiceProviderName, 28 | type: LaunchServiceProviderType, 29 | ) -> None: 30 | self.id = id 31 | self.url = url 32 | self.name = name 33 | self.type = type 34 | 35 | 36 | class Abbrev(Enum): 37 | ELLIPTICAL = "Elliptical" 38 | FAILURE = "Failure" 39 | LEO = "LEO" 40 | SUCCESS = "Success" 41 | 42 | 43 | class StatusName(Enum): 44 | ELLIPTICAL_ORBIT = "Elliptical Orbit" 45 | LAUNCH_FAILURE = "Launch Failure" 46 | LAUNCH_SUCCESSFUL = "Launch Successful" 47 | LOW_EARTH_ORBIT = "Low Earth Orbit" 48 | 49 | 50 | class Status: 51 | id: int 52 | name: StatusName 53 | abbrev: Abbrev 54 | description: str | None 55 | 56 | def __init__(self, id: int, name: StatusName, abbrev: Abbrev, description: str | None) -> None: 57 | self.id = id 58 | self.name = name 59 | self.abbrev = abbrev 60 | self.description = description 61 | 62 | 63 | class MissionType(Enum): 64 | EARTH_SCIENCE = "Earth Science" 65 | TEST_FLIGHT = "Test Flight" 66 | 67 | 68 | class Mission: 69 | id: int 70 | name: str 71 | description: str 72 | launch_designator: None 73 | type: MissionType 74 | orbit: Status 75 | agencies: list[Any] 76 | info_urls: list[Any] 77 | vid_urls: list[Any] 78 | 79 | def __init__( 80 | self, 81 | id: int, 82 | name: str, 83 | description: str, 84 | launch_designator: None, 85 | type: MissionType, 86 | orbit: Status, 87 | agencies: list[Any], 88 | info_urls: list[Any], 89 | vid_urls: list[Any], 90 | ) -> None: 91 | self.id = id 92 | self.name = name 93 | self.description = description 94 | self.launch_designator = launch_designator 95 | self.type = type 96 | self.orbit = orbit 97 | self.agencies = agencies 98 | self.info_urls = info_urls 99 | self.vid_urls = vid_urls 100 | 101 | 102 | class CountryCode(Enum): 103 | KAZ = "KAZ" 104 | USA = "USA" 105 | 106 | 107 | class LocationName(Enum): 108 | BAIKONUR_COSMODROME_REPUBLIC_OF_KAZAKHSTAN = "Baikonur Cosmodrome, Republic of Kazakhstan" 109 | CAPE_CANAVERAL_FL_USA = "Cape Canaveral, FL, USA" 110 | 111 | 112 | class TimezoneName(Enum): 113 | AMERICA_NEW_YORK = "America/New_York" 114 | ASIA_QYZYLORDA = "Asia/Qyzylorda" 115 | 116 | 117 | class Location: 118 | id: int 119 | url: str 120 | name: LocationName 121 | country_code: CountryCode 122 | description: None 123 | map_image: str 124 | timezone_name: TimezoneName 125 | total_launch_count: int 126 | total_landing_count: int 127 | 128 | def __init__( 129 | self, 130 | id: int, 131 | url: str, 132 | name: LocationName, 133 | country_code: CountryCode, 134 | description: None, 135 | map_image: str, 136 | timezone_name: TimezoneName, 137 | total_launch_count: int, 138 | total_landing_count: int, 139 | ) -> None: 140 | self.id = id 141 | self.url = url 142 | self.name = name 143 | self.country_code = country_code 144 | self.description = description 145 | self.map_image = map_image 146 | self.timezone_name = timezone_name 147 | self.total_launch_count = total_launch_count 148 | self.total_landing_count = total_landing_count 149 | 150 | 151 | class PadName(Enum): 152 | LAUNCH_COMPLEX_18_A = "Launch Complex 18A" 153 | LAUNCH_COMPLEX_26_A = "Launch Complex 26A" 154 | THE_15 = "1/5" 155 | 156 | 157 | class Pad: 158 | id: int 159 | url: str 160 | agency_id: int | None 161 | name: PadName 162 | description: None 163 | info_url: None 164 | wiki_url: str 165 | map_url: str 166 | latitude: str 167 | longitude: str 168 | location: Location 169 | country_code: CountryCode 170 | map_image: str 171 | total_launch_count: int 172 | orbital_launch_attempt_count: int 173 | 174 | def __init__( 175 | self, 176 | id: int, 177 | url: str, 178 | agency_id: int | None, 179 | name: PadName, 180 | description: None, 181 | info_url: None, 182 | wiki_url: str, 183 | map_url: str, 184 | latitude: str, 185 | longitude: str, 186 | location: Location, 187 | country_code: CountryCode, 188 | map_image: str, 189 | total_launch_count: int, 190 | orbital_launch_attempt_count: int, 191 | ) -> None: 192 | self.id = id 193 | self.url = url 194 | self.agency_id = agency_id 195 | self.name = name 196 | self.description = description 197 | self.info_url = info_url 198 | self.wiki_url = wiki_url 199 | self.map_url = map_url 200 | self.latitude = latitude 201 | self.longitude = longitude 202 | self.location = location 203 | self.country_code = country_code 204 | self.map_image = map_image 205 | self.total_launch_count = total_launch_count 206 | self.orbital_launch_attempt_count = orbital_launch_attempt_count 207 | 208 | 209 | class Family(Enum): 210 | REDSTONE = "Redstone" 211 | SPUTNIK = "Sputnik" 212 | VANGUARD = "Vanguard" 213 | 214 | 215 | class Configuration: 216 | id: int 217 | url: str 218 | name: str 219 | family: Family 220 | full_name: str 221 | variant: str 222 | 223 | def __init__(self, id: int, url: str, name: str, family: Family, full_name: str, variant: str) -> None: 224 | self.id = id 225 | self.url = url 226 | self.name = name 227 | self.family = family 228 | self.full_name = full_name 229 | self.variant = variant 230 | 231 | 232 | class Rocket: 233 | id: int 234 | configuration: Configuration 235 | 236 | def __init__(self, id: int, configuration: Configuration) -> None: 237 | self.id = id 238 | self.configuration = configuration 239 | 240 | 241 | class LaunchResult: 242 | id: UUID 243 | url: str 244 | slug: str 245 | name: str 246 | status: Status 247 | last_updated: datetime 248 | net: datetime 249 | window_end: datetime 250 | window_start: datetime 251 | net_precision: None 252 | probability: None 253 | weather_concerns: None 254 | holdreason: None 255 | failreason: None 256 | hashtag: None 257 | launch_service_provider: LaunchServiceProvider 258 | rocket: Rocket 259 | mission: Mission 260 | pad: Pad 261 | webcast_live: bool 262 | image: str | None 263 | infographic: None 264 | program: list[Any] 265 | orbital_launch_attempt_count: int 266 | location_launch_attempt_count: int 267 | pad_launch_attempt_count: int 268 | agency_launch_attempt_count: int 269 | orbital_launch_attempt_count_year: int 270 | location_launch_attempt_count_year: int 271 | pad_launch_attempt_count_year: int 272 | agency_launch_attempt_count_year: int 273 | 274 | def __init__( 275 | self, 276 | id: UUID, 277 | url: str, 278 | slug: str, 279 | name: str, 280 | status: Status, 281 | last_updated: datetime, 282 | net: datetime, 283 | window_end: datetime, 284 | window_start: datetime, 285 | net_precision: None, 286 | probability: None, 287 | weather_concerns: None, 288 | holdreason: None, 289 | failreason: None, 290 | hashtag: None, 291 | launch_service_provider: LaunchServiceProvider, 292 | rocket: Rocket, 293 | mission: Mission, 294 | pad: Pad, 295 | webcast_live: bool, 296 | image: str | None, 297 | infographic: None, 298 | program: list[Any], 299 | orbital_launch_attempt_count: int, 300 | location_launch_attempt_count: int, 301 | pad_launch_attempt_count: int, 302 | agency_launch_attempt_count: int, 303 | orbital_launch_attempt_count_year: int, 304 | location_launch_attempt_count_year: int, 305 | pad_launch_attempt_count_year: int, 306 | agency_launch_attempt_count_year: int, 307 | ) -> None: 308 | self.id = id 309 | self.url = url 310 | self.slug = slug 311 | self.name = name 312 | self.status = status 313 | self.last_updated = last_updated 314 | self.net = net 315 | self.window_end = window_end 316 | self.window_start = window_start 317 | self.net_precision = net_precision 318 | self.probability = probability 319 | self.weather_concerns = weather_concerns 320 | self.holdreason = holdreason 321 | self.failreason = failreason 322 | self.hashtag = hashtag 323 | self.launch_service_provider = launch_service_provider 324 | self.rocket = rocket 325 | self.mission = mission 326 | self.pad = pad 327 | self.webcast_live = webcast_live 328 | self.image = image 329 | self.infographic = infographic 330 | self.program = program 331 | self.orbital_launch_attempt_count = orbital_launch_attempt_count 332 | self.location_launch_attempt_count = location_launch_attempt_count 333 | self.pad_launch_attempt_count = pad_launch_attempt_count 334 | self.agency_launch_attempt_count = agency_launch_attempt_count 335 | self.orbital_launch_attempt_count_year = orbital_launch_attempt_count_year 336 | self.location_launch_attempt_count_year = location_launch_attempt_count_year 337 | self.pad_launch_attempt_count_year = pad_launch_attempt_count_year 338 | self.agency_launch_attempt_count_year = agency_launch_attempt_count_year 339 | 340 | 341 | class LaunchResponse: 342 | count: int 343 | next: str 344 | previous: None 345 | results: list[LaunchResult] 346 | 347 | def __init__(self, count: int, next: str, previous: None, results: list[LaunchResult]) -> None: 348 | self.count = count 349 | self.next = next 350 | self.previous = previous 351 | self.results = results 352 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/linux,macos,django,python,windows,intellij+all 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=linux,macos,django,python,windows,intellij+all 3 | 4 | ### Django ### 5 | *.log 6 | *.pot 7 | *.pyc 8 | __pycache__/ 9 | local_settings.py 10 | db.sqlite3 11 | db.sqlite3-journal 12 | media 13 | 14 | # If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ 15 | # in your Git repository. Update and uncomment the following line accordingly. 16 | # /staticfiles/ 17 | 18 | ### Django.Python Stack ### 19 | # Byte-compiled / optimized / DLL files 20 | *.py[cod] 21 | *$py.class 22 | 23 | # C extensions 24 | *.so 25 | 26 | # Distribution / packaging 27 | .Python 28 | build/ 29 | develop-eggs/ 30 | dist/ 31 | downloads/ 32 | eggs/ 33 | .eggs/ 34 | lib/ 35 | lib64/ 36 | parts/ 37 | sdist/ 38 | var/ 39 | wheels/ 40 | share/python-wheels/ 41 | *.egg-info/ 42 | .installed.cfg 43 | *.egg 44 | MANIFEST 45 | 46 | # PyInstaller 47 | # Usually these files are written by a python script from a template 48 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 49 | *.manifest 50 | *.spec 51 | 52 | # Installer logs 53 | pip-log.txt 54 | pip-delete-this-directory.txt 55 | 56 | # Unit test / coverage reports 57 | htmlcov/ 58 | .tox/ 59 | .nox/ 60 | .coverage 61 | .coverage.* 62 | .cache 63 | nosetests.xml 64 | coverage.xml 65 | *.cover 66 | *.py,cover 67 | .hypothesis/ 68 | .pytest_cache/ 69 | cover/ 70 | 71 | # Translations 72 | *.mo 73 | 74 | # Django stuff: 75 | 76 | # Flask stuff: 77 | instance/ 78 | .webassets-cache 79 | 80 | # Scrapy stuff: 81 | .scrapy 82 | 83 | # Sphinx documentation 84 | docs/_build/ 85 | 86 | # PyBuilder 87 | .pybuilder/ 88 | target/ 89 | 90 | # Jupyter Notebook 91 | .ipynb_checkpoints 92 | 93 | # IPython 94 | profile_default/ 95 | ipython_config.py 96 | 97 | # pyenv 98 | # For a library or package, you might want to ignore these files since the code is 99 | # intended to run in multiple environments; otherwise, check them in: 100 | # .python-version 101 | 102 | # pipenv 103 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 104 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 105 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 106 | # install all needed dependencies. 107 | #Pipfile.lock 108 | 109 | # poetry 110 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 111 | # This is especially recommended for binary packages to ensure reproducibility, and is more 112 | # commonly ignored for libraries. 113 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 114 | #poetry.lock 115 | 116 | # pdm 117 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 118 | #pdm.lock 119 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 120 | # in version control. 121 | # https://pdm.fming.dev/#use-with-ide 122 | .pdm.toml 123 | 124 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 125 | __pypackages__/ 126 | 127 | # Celery stuff 128 | celerybeat-schedule 129 | celerybeat.pid 130 | 131 | # SageMath parsed files 132 | *.sage.py 133 | 134 | # Environments 135 | .venv 136 | env/ 137 | venv/ 138 | ENV/ 139 | env.bak/ 140 | venv.bak/ 141 | 142 | # Spyder project settings 143 | .spyderproject 144 | .spyproject 145 | 146 | # Rope project settings 147 | .ropeproject 148 | 149 | # mkdocs documentation 150 | /site 151 | 152 | # mypy 153 | .mypy_cache/ 154 | .dmypy.json 155 | dmypy.json 156 | 157 | # Pyre type checker 158 | .pyre/ 159 | 160 | # pytype static type analyzer 161 | .pytype/ 162 | 163 | # Cython debug symbols 164 | cython_debug/ 165 | 166 | # PyCharm 167 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 168 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 169 | # and can be added to the global gitignore or merged into this file. For a more nuclear 170 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 171 | #.idea/ 172 | 173 | ### Intellij+all ### 174 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 175 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 176 | 177 | # User-specific stuff 178 | .idea/**/workspace.xml 179 | .idea/**/tasks.xml 180 | .idea/**/usage.statistics.xml 181 | .idea/**/dictionaries 182 | .idea/**/shelf 183 | 184 | # AWS User-specific 185 | .idea/**/aws.xml 186 | 187 | # Generated files 188 | .idea/**/contentModel.xml 189 | 190 | # Sensitive or high-churn files 191 | .idea/**/dataSources/ 192 | .idea/**/dataSources.ids 193 | .idea/**/dataSources.local.xml 194 | .idea/**/sqlDataSources.xml 195 | .idea/**/dynamic.xml 196 | .idea/**/uiDesigner.xml 197 | .idea/**/dbnavigator.xml 198 | 199 | # Gradle 200 | .idea/**/gradle.xml 201 | .idea/**/libraries 202 | 203 | # Gradle and Maven with auto-import 204 | # When using Gradle or Maven with auto-import, you should exclude module files, 205 | # since they will be recreated, and may cause churn. Uncomment if using 206 | # auto-import. 207 | # .idea/artifacts 208 | # .idea/compiler.xml 209 | # .idea/jarRepositories.xml 210 | # .idea/modules.xml 211 | # .idea/*.iml 212 | # .idea/modules 213 | # *.iml 214 | # *.ipr 215 | 216 | # CMake 217 | cmake-build-*/ 218 | 219 | # Mongo Explorer plugin 220 | .idea/**/mongoSettings.xml 221 | 222 | # File-based project format 223 | *.iws 224 | 225 | # IntelliJ 226 | out/ 227 | 228 | # mpeltonen/sbt-idea plugin 229 | .idea_modules/ 230 | 231 | # JIRA plugin 232 | atlassian-ide-plugin.xml 233 | 234 | # Cursive Clojure plugin 235 | .idea/replstate.xml 236 | 237 | # SonarLint plugin 238 | .idea/sonarlint/ 239 | 240 | # Crashlytics plugin (for Android Studio and IntelliJ) 241 | com_crashlytics_export_strings.xml 242 | crashlytics.properties 243 | crashlytics-build.properties 244 | fabric.properties 245 | 246 | # Editor-based Rest Client 247 | .idea/httpRequests 248 | 249 | # Android studio 3.1+ serialized cache file 250 | .idea/caches/build_file_checksums.ser 251 | 252 | ### Intellij+all Patch ### 253 | # Ignore everything but code style settings and run configurations 254 | # that are supposed to be shared within teams. 255 | 256 | .idea/* 257 | 258 | !.idea/codeStyles 259 | !.idea/runConfigurations 260 | 261 | ### Linux ### 262 | *~ 263 | 264 | # temporary files which can be created if a process still has a handle open of a deleted file 265 | .fuse_hidden* 266 | 267 | # KDE directory preferences 268 | .directory 269 | 270 | # Linux trash folder which might appear on any partition or disk 271 | .Trash-* 272 | 273 | # .nfs files are created when an open file is removed but is still being accessed 274 | .nfs* 275 | 276 | ### macOS ### 277 | # General 278 | .DS_Store 279 | .AppleDouble 280 | .LSOverride 281 | 282 | # Icon must end with two \r 283 | Icon 284 | 285 | 286 | # Thumbnails 287 | ._* 288 | 289 | # Files that might appear in the root of a volume 290 | .DocumentRevisions-V100 291 | .fseventsd 292 | .Spotlight-V100 293 | .TemporaryItems 294 | .Trashes 295 | .VolumeIcon.icns 296 | .com.apple.timemachine.donotpresent 297 | 298 | # Directories potentially created on remote AFP share 299 | .AppleDB 300 | .AppleDesktop 301 | Network Trash Folder 302 | Temporary Items 303 | .apdisk 304 | 305 | ### macOS Patch ### 306 | # iCloud generated files 307 | *.icloud 308 | 309 | ### Python ### 310 | # Byte-compiled / optimized / DLL files 311 | 312 | # C extensions 313 | 314 | # Distribution / packaging 315 | 316 | # PyInstaller 317 | # Usually these files are written by a python script from a template 318 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 319 | 320 | # Installer logs 321 | 322 | # Unit test / coverage reports 323 | 324 | # Translations 325 | 326 | # Django stuff: 327 | 328 | # Flask stuff: 329 | 330 | # Scrapy stuff: 331 | 332 | # Sphinx documentation 333 | 334 | # PyBuilder 335 | 336 | # Jupyter Notebook 337 | 338 | # IPython 339 | 340 | # pyenv 341 | # For a library or package, you might want to ignore these files since the code is 342 | # intended to run in multiple environments; otherwise, check them in: 343 | # .python-version 344 | 345 | # pipenv 346 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 347 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 348 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 349 | # install all needed dependencies. 350 | 351 | # poetry 352 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 353 | # This is especially recommended for binary packages to ensure reproducibility, and is more 354 | # commonly ignored for libraries. 355 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 356 | 357 | # pdm 358 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 359 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 360 | # in version control. 361 | # https://pdm.fming.dev/#use-with-ide 362 | 363 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 364 | 365 | # Celery stuff 366 | 367 | # SageMath parsed files 368 | 369 | # Environments 370 | 371 | # Spyder project settings 372 | 373 | # Rope project settings 374 | 375 | # mkdocs documentation 376 | 377 | # mypy 378 | 379 | # Pyre type checker 380 | 381 | # pytype static type analyzer 382 | 383 | # Cython debug symbols 384 | 385 | # PyCharm 386 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 387 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 388 | # and can be added to the global gitignore or merged into this file. For a more nuclear 389 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 390 | 391 | ### Python Patch ### 392 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 393 | poetry.toml 394 | 395 | # ruff 396 | .ruff_cache/ 397 | 398 | ### Windows ### 399 | # Windows thumbnail cache files 400 | Thumbs.db 401 | Thumbs.db:encryptable 402 | ehthumbs.db 403 | ehthumbs_vista.db 404 | 405 | # Dump file 406 | *.stackdump 407 | 408 | # Folder config file 409 | [Dd]esktop.ini 410 | 411 | # Recycle Bin used on file shares 412 | $RECYCLE.BIN/ 413 | 414 | # Windows Installer files 415 | *.cab 416 | *.msi 417 | *.msix 418 | *.msm 419 | *.msp 420 | 421 | # Windows shortcuts 422 | *.lnk 423 | 424 | # End of https://www.toptal.com/developers/gitignore/api/linux,macos,django,python,windows,intellij+all 425 | 426 | TEST-*.xml 427 | .env 428 | -------------------------------------------------------------------------------- /src/api/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from random import randrange 2 | from uuid import uuid4 3 | 4 | import pytest 5 | 6 | from api.models import Article, Event, Launch, NewsSite, Provider, Report 7 | 8 | NUMBER_OF_NEWS_SITES = 20 9 | 10 | 11 | @pytest.fixture 12 | def news_sites() -> list[NewsSite]: 13 | sites: list[NewsSite] = [] 14 | for i in range(NUMBER_OF_NEWS_SITES): 15 | site = NewsSite.objects.create(name=f"News Site {i}") 16 | sites.append(site) 17 | 18 | return sites 19 | 20 | 21 | @pytest.fixture 22 | def provider() -> Provider: 23 | return Provider.objects.create(name="Launch Library 2") 24 | 25 | 26 | @pytest.fixture 27 | def launches(provider: Provider) -> list[Launch]: 28 | launches: list[Launch] = [] 29 | for i in range(10): 30 | launch = Launch.objects.create( 31 | launch_id=uuid4(), 32 | name=f"Launch {i}", 33 | provider=provider, 34 | ) 35 | launches.append(launch) 36 | 37 | return launches 38 | 39 | 40 | @pytest.fixture 41 | def events(provider: Provider) -> list[Event]: 42 | events: list[Event] = [] 43 | for i in range(10): 44 | event = Event.objects.create( 45 | event_id=i, 46 | name=f"Event {i}", 47 | provider=provider, 48 | ) 49 | events.append(event) 50 | 51 | return events 52 | 53 | 54 | @pytest.fixture 55 | def articles(news_sites: list[NewsSite], launches: list[Launch], events: list[Event]) -> list[Article]: 56 | articles: list[Article] = [] 57 | for i in range(100): 58 | article = Article.objects.create( 59 | title=f"Article {i}", 60 | summary=f"Description {i}", 61 | url=f"https://example.com/{i}", 62 | image_url=f"https://example.com/{i}.png", 63 | news_site=news_sites[randrange(NUMBER_OF_NEWS_SITES)], 64 | published_at="2021-01-01T00:00:00Z", 65 | updated_at="2021-01-01T00:00:00Z", 66 | ) 67 | articles.append(article) 68 | 69 | article_with_launch_1 = Article.objects.create( 70 | title="Article with Launch 1", 71 | summary="Description of an article with a launch 1", 72 | url="https://example.com/launch_1", 73 | image_url="https://example.com/launch_1.png", 74 | news_site=news_sites[0], 75 | published_at="2021-01-01T00:00:00Z", 76 | updated_at="2021-01-01T00:00:00Z", 77 | ) 78 | article_with_launch_1.launches.add(launches[0]) 79 | articles.append(article_with_launch_1) 80 | 81 | article_with_launch_2 = Article.objects.create( 82 | title="Article with Launch 2", 83 | summary="Description of an article with a launch 2", 84 | url="https://example.com/launch_2", 85 | image_url="https://example.com/launch_2.png", 86 | news_site=news_sites[1], 87 | published_at="2021-01-01T00:00:00Z", 88 | updated_at="2021-01-01T00:00:00Z", 89 | ) 90 | article_with_launch_2.launches.add(launches[1]) 91 | articles.append(article_with_launch_2) 92 | 93 | article_with_event_1 = Article.objects.create( 94 | title="Article with Event 1", 95 | summary="Description of an article with an event 1", 96 | url="https://example.com/event_1", 97 | image_url="https://example.com/event_1.png", 98 | news_site=news_sites[0], 99 | published_at="2021-01-01T00:00:00Z", 100 | updated_at="2021-01-01T00:00:00Z", 101 | ) 102 | article_with_event_1.events.add(events[0]) 103 | articles.append(article_with_event_1) 104 | 105 | article_with_event_2 = Article.objects.create( 106 | title="Article with Event 2", 107 | summary="Description of an article with an event 2", 108 | url="https://example.com/event_2", 109 | image_url="https://example.com/event_2.png", 110 | news_site=news_sites[1], 111 | published_at="2021-01-01T00:00:00Z", 112 | updated_at="2021-01-01T00:00:00Z", 113 | ) 114 | article_with_event_2.events.add(events[1]) 115 | articles.append(article_with_event_2) 116 | 117 | article_in_the_future_1 = Article.objects.create( 118 | title="Article in the future 1", 119 | summary="Description of an article in the future 1", 120 | url="https://example.com/future1", 121 | image_url="https://example.com/future1.png", 122 | news_site=news_sites[2], 123 | published_at="2042-01-01T00:00:00Z", 124 | updated_at="2042-01-01T00:00:00Z", 125 | ) 126 | articles.append(article_in_the_future_1) 127 | 128 | article_in_the_future_2 = Article.objects.create( 129 | title="Article in the future 2", 130 | summary="Description of an article in the future 2", 131 | url="https://example.com/future2", 132 | image_url="https://example.com/future2.png", 133 | news_site=news_sites[3], 134 | published_at="2041-01-01T00:00:00Z", 135 | updated_at="2041-01-01T00:00:00Z", 136 | ) 137 | articles.append(article_in_the_future_2) 138 | 139 | article_in_the_past_1 = Article.objects.create( 140 | title="Article in the past 1", 141 | summary="Description of an article in the past 1", 142 | url="https://example.com/past1", 143 | image_url="https://example.com/past1.png", 144 | news_site=news_sites[4], 145 | published_at="1999-01-01T00:00:00Z", 146 | updated_at="1999-01-01T00:00:00Z", 147 | ) 148 | articles.append(article_in_the_past_1) 149 | 150 | article_in_the_past_2 = Article.objects.create( 151 | title="Article in the past 2", 152 | summary="Description of an article in the past 2", 153 | url="https://example.com/past2", 154 | image_url="https://example.com/past2.png", 155 | news_site=news_sites[5], 156 | published_at="2000-01-01T00:00:00Z", 157 | updated_at="2000-01-01T00:00:00Z", 158 | ) 159 | articles.append(article_in_the_past_2) 160 | 161 | article_with_specific_title = Article.objects.create( 162 | title="Article with specific title", 163 | summary="Description of an article with a specific title", 164 | url="https://example.com/specific", 165 | image_url="https://example.com/specific.png", 166 | news_site=news_sites[6], 167 | published_at="2021-01-01T00:00:00Z", 168 | updated_at="2021-01-01T00:00:00Z", 169 | ) 170 | articles.append(article_with_specific_title) 171 | 172 | article_with_spacex = Article.objects.create( 173 | title="Article with SpaceX", 174 | summary="Description of an article with SpaceX", 175 | url="https://example.com/spacex", 176 | image_url="https://example.com/spacex.png", 177 | news_site=news_sites[7], 178 | published_at="2021-01-01T00:00:00Z", 179 | updated_at="2021-01-01T00:00:00Z", 180 | ) 181 | articles.append(article_with_spacex) 182 | 183 | article_that_is_soft_deleted = Article.objects.create( 184 | title="Deleted Article", 185 | summary="Summary of softdeleted article", 186 | url="https://example.com/delete", 187 | image_url="https://example.com/delete.jpg", 188 | news_site=news_sites[7], 189 | published_at="2021-01-01T00:00:00Z", 190 | updated_at="2021-01-01T00:00:00Z", 191 | is_deleted=True, 192 | ) 193 | 194 | articles.append(article_that_is_soft_deleted) 195 | return articles 196 | 197 | 198 | @pytest.fixture 199 | def reports(news_sites: list[NewsSite]) -> list[Report]: 200 | reports: list[Report] = [] 201 | for i in range(10): 202 | report = Report.objects.create( 203 | title=f"Report {i}", 204 | url=f"https://example.com/report_{i}", 205 | image_url=f"https://example.com/report_{i}.png", 206 | news_site=news_sites[i], 207 | summary=f"Description {i}", 208 | published_at="2021-01-01T00:00:00Z", 209 | updated_at="2021-01-01T00:00:00Z", 210 | ) 211 | reports.append(report) 212 | 213 | report_in_the_future_1 = Report.objects.create( 214 | title="Report in the future 1", 215 | url="https://example.com/report_future_1", 216 | image_url="https://example.com/report_future_1.png", 217 | news_site=news_sites[11], 218 | summary="Description in the future 1", 219 | published_at="2042-01-01T00:00:00Z", 220 | updated_at="2042-01-01T00:00:00Z", 221 | ) 222 | reports.append(report_in_the_future_1) 223 | 224 | report_in_the_future_2 = Report.objects.create( 225 | title="Report in the future 2", 226 | url="https://example.com/report_future_2", 227 | image_url="https://example.com/report_future_2.png", 228 | news_site=news_sites[12], 229 | summary="Description in the future 2", 230 | published_at="2042-01-01T00:00:00Z", 231 | updated_at="2042-01-01T00:00:00Z", 232 | ) 233 | reports.append(report_in_the_future_2) 234 | 235 | reports_in_the_past_1 = Report.objects.create( 236 | title="Report in the past 1", 237 | url="https://example.com/report_past_1", 238 | image_url="https://example.com/report_past_1.png", 239 | news_site=news_sites[13], 240 | summary="Description in the past 1", 241 | published_at="1999-01-01T00:00:00Z", 242 | updated_at="1999-01-01T00:00:00Z", 243 | ) 244 | reports.append(reports_in_the_past_1) 245 | 246 | reports_in_the_past_2 = Report.objects.create( 247 | title="Report in the past 2", 248 | url="https://example.com/report_past_2", 249 | image_url="https://example.com/report_past_2.png", 250 | news_site=news_sites[14], 251 | summary="Description in the past 2", 252 | published_at="1999-01-01T00:00:00Z", 253 | updated_at="1999-01-01T00:00:00Z", 254 | ) 255 | reports.append(reports_in_the_past_2) 256 | 257 | report_with_specific_title = Report.objects.create( 258 | title="Report with specific title", 259 | url="https://example.com/report_specific", 260 | image_url="https://example.com/report_specific.png", 261 | news_site=news_sites[15], 262 | summary="Description of an report with a specific title", 263 | published_at="2021-01-01T00:00:00Z", 264 | updated_at="2021-01-01T00:00:00Z", 265 | ) 266 | reports.append(report_with_specific_title) 267 | 268 | report_with_spacex = Report.objects.create( 269 | title="Report with SpaceX", 270 | url="https://example.com/report_spacex", 271 | image_url="https://example.com/report_spacex.png", 272 | news_site=news_sites[16], 273 | summary="Description of an report with SpaceX", 274 | published_at="2021-01-01T00:00:00Z", 275 | updated_at="2021-01-01T00:00:00Z", 276 | ) 277 | reports.append(report_with_spacex) 278 | 279 | report_that_is_soft_deleted = Report.objects.create( 280 | title="Deleted Report", 281 | summary="Summary of softdeleted report", 282 | url="https://example.com/delete", 283 | image_url="https://example.com/delete.jpg", 284 | news_site=news_sites[7], 285 | published_at="2021-01-01T00:00:00Z", 286 | updated_at="2021-01-01T00:00:00Z", 287 | is_deleted=True, 288 | ) 289 | 290 | reports.append(report_that_is_soft_deleted) 291 | return reports 292 | -------------------------------------------------------------------------------- /src/api/tests/test_articles_endpoint.py: -------------------------------------------------------------------------------- 1 | """This module tests both articles and the blogs endpoints. 2 | Most code is shared between the two. 3 | """ 4 | 5 | import pytest 6 | from django.test.client import Client 7 | 8 | from api.models import Article, Event, Launch, NewsSite 9 | 10 | 11 | @pytest.mark.django_db 12 | class TestArticlesEndpoint: 13 | def test_get_articles(self, client: Client, articles: list[Article]) -> None: 14 | response = client.get("/v4/articles/") 15 | assert response.status_code == 200 16 | 17 | data = response.json() 18 | 19 | assert all(article["title"] in [article.title for article in articles] for article in data["results"]) 20 | assert all( 21 | article["news_site"] in [fixture_article.news_site.name for fixture_article in articles] 22 | for article in data["results"] 23 | ) 24 | assert len(data["results"]) == 10 25 | 26 | def test_get_single_article(self, client: Client, articles: list[Article]) -> None: 27 | response = client.get(f"/v4/articles/{articles[0].id}/") 28 | assert response.status_code == 200 29 | 30 | data = response.json() 31 | 32 | assert data["title"] == articles[0].title 33 | assert data["news_site"] == articles[0].news_site.name 34 | 35 | def test_limit_articles(self, client: Client, articles: list[Article]) -> None: 36 | response = client.get("/v4/articles/?limit=5") 37 | assert response.status_code == 200 38 | 39 | data = response.json() 40 | 41 | assert len(data["results"]) == 5 42 | 43 | def test_get_all_articles_with_launches(self, client: Client, articles: list[Article]) -> None: 44 | response = client.get("/v4/articles/?has_launch=true") 45 | assert response.status_code == 200 46 | 47 | data = response.json() 48 | 49 | assert len(data["results"]) == 2 50 | 51 | def test_get_all_articles_with_events(self, client: Client, articles: list[Article]) -> None: 52 | response = client.get("/v4/articles/?has_event=true") 53 | assert response.status_code == 200 54 | 55 | data = response.json() 56 | 57 | assert len(data["results"]) == 2 58 | 59 | def test_get_articles_with_launch(self, client: Client, articles: list[Article], launches: list[Launch]) -> None: 60 | response = client.get(f"/v4/articles/?launch={launches[0].launch_id}") 61 | assert response.status_code == 200 62 | 63 | data = response.json() 64 | assert data["results"][0]["title"] == articles[100].title 65 | assert data["results"][0]["launches"][0]["launch_id"] == str(launches[0].launch_id) 66 | assert data["results"][0]["launches"][0]["provider"] == launches[0].provider.name 67 | assert len(data["results"]) == 1 68 | 69 | def test_get_articles_with_event(self, client: Client, articles: list[Article], events: list[Event]) -> None: 70 | article_with_event = [article for article in articles if article.title == "Article with Event 1"][0] 71 | response = client.get(f"/v4/articles/?event={events[0].event_id}") 72 | assert response.status_code == 200 73 | 74 | data = response.json() 75 | assert data["results"][0]["title"] == article_with_event.title 76 | assert data["results"][0]["events"][0]["event_id"] == events[0].event_id 77 | assert data["results"][0]["events"][0]["provider"] == events[0].provider.name 78 | assert len(data["results"]) == 1 79 | 80 | def test_get_articles_by_news_site( 81 | self, client: Client, articles: list[Article], news_sites: list[NewsSite] 82 | ) -> None: 83 | filtered_articles = [article for article in articles if article.news_site.name == news_sites[0].name] 84 | 85 | response = client.get(f"/v4/articles/?news_site={news_sites[0].name}") 86 | assert response.status_code == 200 87 | 88 | data = response.json() 89 | assert data["results"][0]["title"] == filtered_articles[0].title 90 | assert data["results"][0]["news_site"] == news_sites[0].name 91 | assert len(data["results"]) == len(filtered_articles) 92 | 93 | def test_get_articles_by_multiple_news_sites( 94 | self, client: Client, articles: list[Article], news_sites: list[NewsSite] 95 | ) -> None: 96 | filtered_articles = [ 97 | article for article in articles if article.news_site.name in [news_sites[0].name, news_sites[1].name] 98 | ] 99 | 100 | response = client.get(f"/v4/articles/?news_site={news_sites[0].name},{news_sites[1].name}&limit=100") 101 | assert response.status_code == 200 102 | 103 | data = response.json() 104 | assert len(data["results"]) == len(filtered_articles) 105 | assert all(article["title"] in [article.title for article in filtered_articles] for article in data["results"]) 106 | 107 | def test_get_articles_with_offset(self, client: Client, articles: list[Article]) -> None: 108 | response = client.get("/v4/articles/?offset=5") 109 | assert response.status_code == 200 110 | 111 | data = response.json() 112 | 113 | assert len(data["results"]) == 10 114 | 115 | def test_get_articles_with_limit(self, client: Client, articles: list[Article]) -> None: 116 | response = client.get("/v4/articles/?limit=5") 117 | assert response.status_code == 200 118 | 119 | data = response.json() 120 | 121 | assert len(data["results"]) == 5 122 | 123 | def test_get_articles_with_ordering(self, client: Client, articles: list[Article]) -> None: 124 | sorted_data = sorted(articles, key=lambda article: article.published_at, reverse=True) 125 | 126 | response = client.get("/v4/articles/?ordering=-published_at") 127 | assert response.status_code == 200 128 | 129 | data = response.json() 130 | 131 | assert data["results"][0]["title"] == sorted_data[0].title 132 | 133 | def test_get_articles_published_at_greater_then(self, client: Client, articles: list[Article]) -> None: 134 | articles_in_the_future = list( 135 | filter( 136 | lambda article: article.title.startswith("Article in the future"), 137 | articles, 138 | ) 139 | ) 140 | 141 | response = client.get("/v4/articles/?published_at_gt=2040-10-01") 142 | assert response.status_code == 200 143 | 144 | data = response.json() 145 | 146 | assert all( 147 | article["title"] in [article.title for article in articles_in_the_future] for article in data["results"] 148 | ) 149 | assert len(data["results"]) == 2 150 | 151 | def test_get_articles_published_at_lower_then(self, client: Client, articles: list[Article]) -> None: 152 | articles_in_the_past = list( 153 | filter( 154 | lambda article: article.title.startswith("Article in the past"), 155 | articles, 156 | ) 157 | ) 158 | 159 | response = client.get("/v4/articles/?published_at_lt=2001-01-01") 160 | assert response.status_code == 200 161 | 162 | data = response.json() 163 | 164 | assert all( 165 | article["title"] in [article.title for article in articles_in_the_past] for article in data["results"] 166 | ) 167 | assert len(data["results"]) == 2 168 | 169 | def test_get_article_search_articles(self, client: Client, articles: list[Article]) -> None: 170 | response = client.get("/v4/articles/?search=title") 171 | assert response.status_code == 200 172 | 173 | data = response.json() 174 | 175 | assert data["results"][0]["title"] == "Article with specific title" 176 | assert len(data["results"]) == 1 177 | 178 | def test_get_articles_search_summary(self, client: Client, articles: list[Article]) -> None: 179 | response = client.get("/v4/articles/?search=title") 180 | assert response.status_code == 200 181 | 182 | data = response.json() 183 | 184 | assert data["results"][0]["summary"] == "Description of an article with a specific title" 185 | assert len(data["results"]) == 1 186 | 187 | def test_get_articles_title_contains(self, client: Client, articles: list[Article]) -> None: 188 | response = client.get("/v4/articles/?title_contains=Article with specific") 189 | assert response.status_code == 200 190 | 191 | data = response.json() 192 | 193 | assert data["results"][0]["title"] == "Article with specific title" 194 | assert len(data["results"]) == 1 195 | 196 | def test_get_articles_title_contains_one(self, client: Client, articles: list[Article]) -> None: 197 | response = client.get("/v4/articles/?title_contains_one=SpaceX, specific") 198 | assert response.status_code == 200 199 | 200 | data = response.json() 201 | 202 | assert data["results"][0]["title"] == "Article with specific title" 203 | assert len(data["results"]) == 2 204 | 205 | def test_get_articles_title_contains_all(self, client: Client, articles: list[Article]) -> None: 206 | response = client.get("/v4/articles/?title_contains_all=specific, with, title") 207 | assert response.status_code == 200 208 | 209 | data = response.json() 210 | 211 | assert data["results"][0]["title"] == "Article with specific title" 212 | assert len(data["results"]) == 1 213 | 214 | def test_get_articles_summary_contains(self, client: Client, articles: list[Article]) -> None: 215 | response = client.get("/v4/articles/?summary_contains=specific") 216 | assert response.status_code == 200 217 | 218 | data = response.json() 219 | 220 | assert data["results"][0]["title"] == "Article with specific title" 221 | assert len(data["results"]) == 1 222 | 223 | def test_get_articles_summary_contains_one(self, client: Client, articles: list[Article]) -> None: 224 | response = client.get("/v4/articles/?summary_contains_one=SpaceX, specific") 225 | assert response.status_code == 200 226 | 227 | data = response.json() 228 | 229 | assert data["results"][0]["title"] == "Article with specific title" 230 | assert len(data["results"]) == 2 231 | 232 | def test_get_articles_summary_contains_all(self, client: Client, articles: list[Article]) -> None: 233 | response = client.get("/v4/articles/?summary_contains_all=specific, with, title") 234 | assert response.status_code == 200 235 | 236 | data = response.json() 237 | 238 | assert data["results"][0]["title"] == "Article with specific title" 239 | assert len(data["results"]) == 1 240 | 241 | def test_soft_deleted(self, client: Client, articles: list[Article]) -> None: 242 | response = client.get("/v4/articles/?title_contains=Deleted") 243 | assert response.status_code == 200 244 | 245 | data = response.json() 246 | 247 | assert len(data["results"]) == 0 248 | 249 | def test_news_site_exclude(self, client: Client, articles: list[Article]) -> None: 250 | response = client.get("/v4/articles/?news_site_exclude=SpaceNews") 251 | assert response.status_code == 200 252 | 253 | data = response.json() 254 | 255 | assert all(article["news_site"] != "SpaceNews" for article in data["results"]) 256 | 257 | def test_news_site_exclude_multiple(self, client: Client, articles: list[Article]) -> None: 258 | response = client.get("/v4/articles/?news_site_exclude=SpaceNews,SpaceFlightNow") 259 | assert response.status_code == 200 260 | 261 | data = response.json() 262 | 263 | assert all(article["news_site"] not in ["SpaceNews", "SpaceFlightNow"] for article in data["results"]) 264 | -------------------------------------------------------------------------------- /src/api/utils/types/event_response.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from enum import Enum 3 | from typing import Any 4 | from uuid import UUID 5 | 6 | 7 | class TypeEnum(Enum): 8 | COMMERCIAL = "Commercial" 9 | GOVERNMENT = "Government" 10 | MULTINATIONAL = "Multinational" 11 | 12 | 13 | class LaunchServiceProvider: 14 | id: int 15 | url: str 16 | name: str 17 | type: TypeEnum 18 | 19 | def __init__(self, id: int, url: str, name: str, type: TypeEnum) -> None: 20 | self.id = id 21 | self.url = url 22 | self.name = name 23 | self.type = type 24 | 25 | 26 | class MissionPatch: 27 | id: int 28 | name: str 29 | priority: int 30 | image_url: str 31 | agency: LaunchServiceProvider 32 | 33 | def __init__( 34 | self, 35 | id: int, 36 | name: str, 37 | priority: int, 38 | image_url: str, 39 | agency: LaunchServiceProvider, 40 | ) -> None: 41 | self.id = id 42 | self.name = name 43 | self.priority = priority 44 | self.image_url = image_url 45 | self.agency = agency 46 | 47 | 48 | class LocationEnum(Enum): 49 | BOCA_CHICA_TEXAS = "Boca Chica, Texas" 50 | INTERNATIONAL_SPACE_STATION = "International Space Station" 51 | THE_24000000_KM_FROM_SUN = "24,000,000 km from Sun." 52 | 53 | 54 | class TypeClass: 55 | id: int 56 | name: str 57 | 58 | def __init__(self, id: int, name: str) -> None: 59 | self.id = id 60 | self.name = name 61 | 62 | 63 | class Spacestation: 64 | id: int 65 | url: str 66 | name: LocationEnum 67 | status: TypeClass 68 | orbit: str 69 | image_url: str 70 | founded: datetime | None 71 | description: str | None 72 | 73 | def __init__( 74 | self, 75 | id: int, 76 | url: str, 77 | name: LocationEnum, 78 | status: TypeClass, 79 | orbit: str, 80 | image_url: str, 81 | founded: datetime | None, 82 | description: str | None, 83 | ) -> None: 84 | self.id = id 85 | self.url = url 86 | self.name = name 87 | self.status = status 88 | self.orbit = orbit 89 | self.image_url = image_url 90 | self.founded = founded 91 | self.description = description 92 | 93 | 94 | class Spacewalk: 95 | id: int 96 | url: str 97 | name: str 98 | start: datetime 99 | end: datetime 100 | duration: str 101 | location: LocationEnum 102 | 103 | def __init__( 104 | self, 105 | id: int, 106 | url: str, 107 | name: str, 108 | start: datetime, 109 | end: datetime, 110 | duration: str, 111 | location: LocationEnum, 112 | ) -> None: 113 | self.id = id 114 | self.url = url 115 | self.name = name 116 | self.start = start 117 | self.end = end 118 | self.duration = duration 119 | self.location = location 120 | 121 | 122 | class Expedition: 123 | id: int 124 | url: str 125 | name: str 126 | start: datetime 127 | end: datetime 128 | spacestation: Spacestation 129 | mission_patches: list[MissionPatch] 130 | spacewalks: list[Spacewalk] 131 | 132 | def __init__( 133 | self, 134 | id: int, 135 | url: str, 136 | name: str, 137 | start: datetime, 138 | end: datetime, 139 | spacestation: Spacestation, 140 | mission_patches: list[MissionPatch], 141 | spacewalks: list[Spacewalk], 142 | ) -> None: 143 | self.id = id 144 | self.url = url 145 | self.name = name 146 | self.start = start 147 | self.end = end 148 | self.spacestation = spacestation 149 | self.mission_patches = mission_patches 150 | self.spacewalks = spacewalks 151 | 152 | 153 | class Code(Enum): 154 | EN = "en" 155 | 156 | 157 | class Name(Enum): 158 | ENGLISH = "English" 159 | 160 | 161 | class Language: 162 | id: int 163 | name: Name 164 | code: Code 165 | 166 | def __init__(self, id: int, name: Name, code: Code) -> None: 167 | self.id = id 168 | self.name = name 169 | self.code = code 170 | 171 | 172 | class URL: 173 | priority: int 174 | source: str 175 | title: str 176 | description: str 177 | feature_image: str | None 178 | url: str 179 | type: TypeClass | None 180 | language: Language 181 | 182 | def __init__( 183 | self, 184 | priority: int, 185 | source: str, 186 | title: str, 187 | description: str, 188 | feature_image: str | None, 189 | url: str, 190 | type: TypeClass | None, 191 | language: Language, 192 | ) -> None: 193 | self.priority = priority 194 | self.source = source 195 | self.title = title 196 | self.description = description 197 | self.feature_image = feature_image 198 | self.url = url 199 | self.type = type 200 | self.language = language 201 | 202 | 203 | class Status: 204 | id: int 205 | name: str 206 | abbrev: str 207 | description: str | None 208 | 209 | def __init__(self, id: int, name: str, abbrev: str, description: str | None) -> None: 210 | self.id = id 211 | self.name = name 212 | self.abbrev = abbrev 213 | self.description = description 214 | 215 | 216 | class Mission: 217 | id: int 218 | name: str 219 | description: str 220 | launch_designator: None 221 | type: str 222 | orbit: Status 223 | agencies: list[Any] 224 | info_urls: list[Any] 225 | vid_urls: list[Any] 226 | 227 | def __init__( 228 | self, 229 | id: int, 230 | name: str, 231 | description: str, 232 | launch_designator: None, 233 | type: str, 234 | orbit: Status, 235 | agencies: list[Any], 236 | info_urls: list[Any], 237 | vid_urls: list[Any], 238 | ) -> None: 239 | self.id = id 240 | self.name = name 241 | self.description = description 242 | self.launch_designator = launch_designator 243 | self.type = type 244 | self.orbit = orbit 245 | self.agencies = agencies 246 | self.info_urls = info_urls 247 | self.vid_urls = vid_urls 248 | 249 | 250 | class LocationClass: 251 | id: int 252 | url: str 253 | name: str 254 | country_code: str 255 | description: None 256 | map_image: str 257 | timezone_name: str 258 | total_launch_count: int 259 | total_landing_count: int 260 | 261 | def __init__( 262 | self, 263 | id: int, 264 | url: str, 265 | name: str, 266 | country_code: str, 267 | description: None, 268 | map_image: str, 269 | timezone_name: str, 270 | total_launch_count: int, 271 | total_landing_count: int, 272 | ) -> None: 273 | self.id = id 274 | self.url = url 275 | self.name = name 276 | self.country_code = country_code 277 | self.description = description 278 | self.map_image = map_image 279 | self.timezone_name = timezone_name 280 | self.total_launch_count = total_launch_count 281 | self.total_landing_count = total_landing_count 282 | 283 | 284 | class Pad: 285 | id: int 286 | url: str 287 | agency_id: None 288 | name: str 289 | description: None 290 | info_url: None 291 | wiki_url: str 292 | map_url: str 293 | latitude: str 294 | longitude: str 295 | location: LocationClass 296 | country_code: str 297 | map_image: str 298 | total_launch_count: int 299 | orbital_launch_attempt_count: int 300 | 301 | def __init__( 302 | self, 303 | id: int, 304 | url: str, 305 | agency_id: None, 306 | name: str, 307 | description: None, 308 | info_url: None, 309 | wiki_url: str, 310 | map_url: str, 311 | latitude: str, 312 | longitude: str, 313 | location: LocationClass, 314 | country_code: str, 315 | map_image: str, 316 | total_launch_count: int, 317 | orbital_launch_attempt_count: int, 318 | ) -> None: 319 | self.id = id 320 | self.url = url 321 | self.agency_id = agency_id 322 | self.name = name 323 | self.description = description 324 | self.info_url = info_url 325 | self.wiki_url = wiki_url 326 | self.map_url = map_url 327 | self.latitude = latitude 328 | self.longitude = longitude 329 | self.location = location 330 | self.country_code = country_code 331 | self.map_image = map_image 332 | self.total_launch_count = total_launch_count 333 | self.orbital_launch_attempt_count = orbital_launch_attempt_count 334 | 335 | 336 | class Program: 337 | id: int 338 | url: str 339 | name: str 340 | description: str 341 | agencies: list[LaunchServiceProvider] 342 | image_url: str 343 | start_date: datetime 344 | end_date: None 345 | info_url: str | None 346 | wiki_url: str 347 | mission_patches: list[Any] 348 | 349 | def __init__( 350 | self, 351 | id: int, 352 | url: str, 353 | name: str, 354 | description: str, 355 | agencies: list[LaunchServiceProvider], 356 | image_url: str, 357 | start_date: datetime, 358 | end_date: None, 359 | info_url: str | None, 360 | wiki_url: str, 361 | mission_patches: list[Any], 362 | ) -> None: 363 | self.id = id 364 | self.url = url 365 | self.name = name 366 | self.description = description 367 | self.agencies = agencies 368 | self.image_url = image_url 369 | self.start_date = start_date 370 | self.end_date = end_date 371 | self.info_url = info_url 372 | self.wiki_url = wiki_url 373 | self.mission_patches = mission_patches 374 | 375 | 376 | class Configuration: 377 | id: int 378 | url: str 379 | name: str 380 | family: str 381 | full_name: str 382 | variant: str 383 | 384 | def __init__(self, id: int, url: str, name: str, family: str, full_name: str, variant: str) -> None: 385 | self.id = id 386 | self.url = url 387 | self.name = name 388 | self.family = family 389 | self.full_name = full_name 390 | self.variant = variant 391 | 392 | 393 | class Rocket: 394 | id: int 395 | configuration: Configuration 396 | 397 | def __init__(self, id: int, configuration: Configuration) -> None: 398 | self.id = id 399 | self.configuration = configuration 400 | 401 | 402 | class Launch: 403 | id: UUID 404 | url: str 405 | slug: str 406 | name: str 407 | status: Status 408 | last_updated: datetime 409 | net: datetime 410 | window_end: datetime 411 | window_start: datetime 412 | net_precision: None 413 | probability: int 414 | weather_concerns: None 415 | holdreason: str | None 416 | failreason: str | None 417 | hashtag: str | None 418 | launch_service_provider: LaunchServiceProvider 419 | rocket: Rocket 420 | mission: Mission 421 | pad: Pad 422 | webcast_live: bool 423 | image: str 424 | infographic: None 425 | program: list[Program] 426 | orbital_launch_attempt_count: int 427 | location_launch_attempt_count: int 428 | pad_launch_attempt_count: int 429 | agency_launch_attempt_count: int 430 | orbital_launch_attempt_count_year: int 431 | location_launch_attempt_count_year: int 432 | pad_launch_attempt_count_year: int 433 | agency_launch_attempt_count_year: int 434 | 435 | def __init__( 436 | self, 437 | id: UUID, 438 | url: str, 439 | slug: str, 440 | name: str, 441 | status: Status, 442 | last_updated: datetime, 443 | net: datetime, 444 | window_end: datetime, 445 | window_start: datetime, 446 | net_precision: None, 447 | probability: int, 448 | weather_concerns: None, 449 | holdreason: str | None, 450 | failreason: str | None, 451 | hashtag: str | None, 452 | launch_service_provider: LaunchServiceProvider, 453 | rocket: Rocket, 454 | mission: Mission, 455 | pad: Pad, 456 | webcast_live: bool, 457 | image: str, 458 | infographic: None, 459 | program: list[Program], 460 | orbital_launch_attempt_count: int, 461 | location_launch_attempt_count: int, 462 | pad_launch_attempt_count: int, 463 | agency_launch_attempt_count: int, 464 | orbital_launch_attempt_count_year: int, 465 | location_launch_attempt_count_year: int, 466 | pad_launch_attempt_count_year: int, 467 | agency_launch_attempt_count_year: int, 468 | ) -> None: 469 | self.id = id 470 | self.url = url 471 | self.slug = slug 472 | self.name = name 473 | self.status = status 474 | self.last_updated = last_updated 475 | self.net = net 476 | self.window_end = window_end 477 | self.window_start = window_start 478 | self.net_precision = net_precision 479 | self.probability = probability 480 | self.weather_concerns = weather_concerns 481 | self.holdreason = holdreason 482 | self.failreason = failreason 483 | self.hashtag = hashtag 484 | self.launch_service_provider = launch_service_provider 485 | self.rocket = rocket 486 | self.mission = mission 487 | self.pad = pad 488 | self.webcast_live = webcast_live 489 | self.image = image 490 | self.infographic = infographic 491 | self.program = program 492 | self.orbital_launch_attempt_count = orbital_launch_attempt_count 493 | self.location_launch_attempt_count = location_launch_attempt_count 494 | self.pad_launch_attempt_count = pad_launch_attempt_count 495 | self.agency_launch_attempt_count = agency_launch_attempt_count 496 | self.orbital_launch_attempt_count_year = orbital_launch_attempt_count_year 497 | self.location_launch_attempt_count_year = location_launch_attempt_count_year 498 | self.pad_launch_attempt_count_year = pad_launch_attempt_count_year 499 | self.agency_launch_attempt_count_year = agency_launch_attempt_count_year 500 | 501 | 502 | class EventResult: 503 | id: int 504 | url: str 505 | slug: str 506 | name: str 507 | updates: list[Any] 508 | last_updated: datetime 509 | type: TypeClass 510 | description: str 511 | webcast_live: bool 512 | location: LocationEnum 513 | news_url: str | None 514 | video_url: str | None 515 | info_urls: list[URL] 516 | vid_urls: list[URL] 517 | feature_image: str 518 | date: datetime 519 | date_precision: None 520 | duration: None 521 | agencies: list[Any] 522 | launches: list[Launch] 523 | expeditions: list[Expedition] 524 | spacestations: list[Spacestation] 525 | program: list[Program] 526 | 527 | def __init__( 528 | self, 529 | id: int, 530 | url: str, 531 | slug: str, 532 | name: str, 533 | updates: list[Any], 534 | last_updated: datetime, 535 | type: TypeClass, 536 | description: str, 537 | webcast_live: bool, 538 | location: LocationEnum, 539 | news_url: str | None, 540 | video_url: str | None, 541 | info_urls: list[URL], 542 | vid_urls: list[URL], 543 | feature_image: str, 544 | date: datetime, 545 | date_precision: None, 546 | duration: None, 547 | agencies: list[Any], 548 | launches: list[Launch], 549 | expeditions: list[Expedition], 550 | spacestations: list[Spacestation], 551 | program: list[Program], 552 | ) -> None: 553 | self.id = id 554 | self.url = url 555 | self.slug = slug 556 | self.name = name 557 | self.updates = updates 558 | self.last_updated = last_updated 559 | self.type = type 560 | self.description = description 561 | self.webcast_live = webcast_live 562 | self.location = location 563 | self.news_url = news_url 564 | self.video_url = video_url 565 | self.info_urls = info_urls 566 | self.vid_urls = vid_urls 567 | self.feature_image = feature_image 568 | self.date = date 569 | self.date_precision = date_precision 570 | self.duration = duration 571 | self.agencies = agencies 572 | self.launches = launches 573 | self.expeditions = expeditions 574 | self.spacestations = spacestations 575 | self.program = program 576 | 577 | 578 | class EventResponse: 579 | count: int 580 | next: str 581 | previous: None 582 | results: list[EventResult] 583 | 584 | def __init__(self, count: int, next: str, previous: None, results: list[EventResult]) -> None: 585 | self.count = count 586 | self.next = next 587 | self.previous = previous 588 | self.results = results 589 | --------------------------------------------------------------------------------