├── 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 | 
2 |
3 | [](https://spaceflightnewsapi.net/)
4 | [](https://api.spaceflightnewsapi.net/v4/docs)
5 | [](https://github.com/TheSpaceDevs/spaceflightnewsapi/releases/tag/v4.0.4)
6 | [](https://discord.gg/p7ntkNA)
7 | [](https://twitter.com/the_snapi)
8 | [](https://www.patreon.com/TheSpaceDevs)
9 |
10 | [](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 |
2 |
--------------------------------------------------------------------------------
/.github/profile/assets/badge_snapi_doc.svg:
--------------------------------------------------------------------------------
1 |
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 |
--------------------------------------------------------------------------------