diff --git a/pyswiss/apps/__init__.py b/pyswiss/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyswiss/apps/charts/__init__.py b/pyswiss/apps/charts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyswiss/apps/charts/apps.py b/pyswiss/apps/charts/apps.py new file mode 100644 index 0000000..ea2efb7 --- /dev/null +++ b/pyswiss/apps/charts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ChartsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.charts' diff --git a/pyswiss/apps/charts/calc.py b/pyswiss/apps/charts/calc.py new file mode 100644 index 0000000..a471f99 --- /dev/null +++ b/pyswiss/apps/charts/calc.py @@ -0,0 +1,92 @@ +""" +Core ephemeris calculation logic — shared by views and management commands. +""" +from django.conf import settings as django_settings +import swisseph as swe + + +DEFAULT_HOUSE_SYSTEM = 'O' # Porphyry + +SIGNS = [ + 'Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', + 'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces', +] + +SIGN_ELEMENT = { + 'Aries': 'Fire', 'Leo': 'Fire', 'Sagittarius': 'Fire', + 'Taurus': 'Earth', 'Virgo': 'Earth', 'Capricorn': 'Earth', + 'Gemini': 'Air', 'Libra': 'Air', 'Aquarius': 'Air', + 'Cancer': 'Water', 'Scorpio': 'Water', 'Pisces': 'Water', +} + +PLANET_CODES = { + 'Sun': swe.SUN, + 'Moon': swe.MOON, + 'Mercury': swe.MERCURY, + 'Venus': swe.VENUS, + 'Mars': swe.MARS, + 'Jupiter': swe.JUPITER, + 'Saturn': swe.SATURN, + 'Uranus': swe.URANUS, + 'Neptune': swe.NEPTUNE, + 'Pluto': swe.PLUTO, +} + + +def set_ephe_path(): + ephe_path = getattr(django_settings, 'SWISSEPH_PATH', None) + if ephe_path: + swe.set_ephe_path(ephe_path) + + +def get_sign(lon): + return SIGNS[int(lon // 30) % 12] + + +def get_julian_day(dt): + return swe.julday( + dt.year, dt.month, dt.day, + dt.hour + dt.minute / 60 + dt.second / 3600, + ) + + +def get_planet_positions(jd): + flag = swe.FLG_SWIEPH | swe.FLG_SPEED + planets = {} + for name, code in PLANET_CODES.items(): + pos, _ = swe.calc_ut(jd, code, flag) + degree = pos[0] + planets[name] = { + 'sign': get_sign(degree), + 'degree': degree, + 'retrograde': pos[3] < 0, + } + return planets + + +def get_element_counts(planets): + sign_counts = {s: 0 for s in SIGNS} + counts = {'Fire': 0, 'Water': 0, 'Earth': 0, 'Air': 0} + + for data in planets.values(): + sign = data['sign'] + counts[SIGN_ELEMENT[sign]] += 1 + sign_counts[sign] += 1 + + # Time: highest planet concentration in a single sign, minus 1 + counts['Time'] = max(sign_counts.values()) - 1 + + # Space: longest consecutive run of occupied signs (circular), minus 1 + indices = [i for i, s in enumerate(SIGNS) if sign_counts[s] > 0] + max_seq = 0 + for start in range(len(indices)): + seq_len = 1 + for offset in range(1, len(indices)): + if (indices[start] + offset) % len(SIGNS) in indices: + seq_len += 1 + else: + break + max_seq = max(max_seq, seq_len) + counts['Space'] = max_seq - 1 + + return counts diff --git a/pyswiss/apps/charts/management/__init__.py b/pyswiss/apps/charts/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyswiss/apps/charts/management/commands/__init__.py b/pyswiss/apps/charts/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyswiss/apps/charts/management/commands/populate_ephemeris.py b/pyswiss/apps/charts/management/commands/populate_ephemeris.py new file mode 100644 index 0000000..bb9f675 --- /dev/null +++ b/pyswiss/apps/charts/management/commands/populate_ephemeris.py @@ -0,0 +1,49 @@ +from datetime import date, datetime, timedelta, timezone + +from django.core.management.base import BaseCommand + +from apps.charts.calc import get_element_counts, get_julian_day, get_planet_positions, set_ephe_path +from apps.charts.models import EphemerisSnapshot + + +class Command(BaseCommand): + help = 'Pre-compute ephemeris snapshots for a date range (one per day at noon UTC).' + + def add_arguments(self, parser): + parser.add_argument('--date-from', required=True, help='Start date (YYYY-MM-DD)') + parser.add_argument('--date-to', required=True, help='End date (YYYY-MM-DD, inclusive)') + + def handle(self, *args, **options): + set_ephe_path() + + date_from = date.fromisoformat(options['date_from']) + date_to = date.fromisoformat(options['date_to']) + + current = date_from + count = 0 + while current <= date_to: + dt = datetime(current.year, current.month, current.day, + 12, 0, 0, tzinfo=timezone.utc) + jd = get_julian_day(dt) + planets = get_planet_positions(jd) + elements = get_element_counts(planets) + + EphemerisSnapshot.objects.update_or_create( + dt=dt, + defaults={ + 'fire': elements['Fire'], + 'water': elements['Water'], + 'earth': elements['Earth'], + 'air': elements['Air'], + 'time_el': elements['Time'], + 'space_el': elements['Space'], + 'chart_data': {'planets': planets}, + }, + ) + current += timedelta(days=1) + count += 1 + + if options['verbosity'] > 0: + self.stdout.write( + self.style.SUCCESS(f'Created/updated {count} snapshot(s).') + ) diff --git a/pyswiss/apps/charts/migrations/0001_initial.py b/pyswiss/apps/charts/migrations/0001_initial.py new file mode 100644 index 0000000..640f280 --- /dev/null +++ b/pyswiss/apps/charts/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 6.0.4 on 2026-04-13 20:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='EphemerisSnapshot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('dt', models.DateTimeField(db_index=True, unique=True)), + ('fire', models.PositiveSmallIntegerField()), + ('water', models.PositiveSmallIntegerField()), + ('earth', models.PositiveSmallIntegerField()), + ('air', models.PositiveSmallIntegerField()), + ('time_el', models.PositiveSmallIntegerField()), + ('space_el', models.PositiveSmallIntegerField()), + ('chart_data', models.JSONField()), + ], + options={ + 'ordering': ['dt'], + }, + ), + ] diff --git a/pyswiss/apps/charts/migrations/__init__.py b/pyswiss/apps/charts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyswiss/apps/charts/models.py b/pyswiss/apps/charts/models.py new file mode 100644 index 0000000..d1b8482 --- /dev/null +++ b/pyswiss/apps/charts/models.py @@ -0,0 +1,36 @@ +from django.db import models + + +class EphemerisSnapshot(models.Model): + """Pre-computed chart data for a single point in time. + + Element counts are stored as denormalised columns for fast DB-level range + filtering. Full planet/house data lives in chart_data (JSONField) for + response serialisation. + """ + + dt = models.DateTimeField(unique=True, db_index=True) + + # Denormalised element counts — indexed for range queries + fire = models.PositiveSmallIntegerField() + water = models.PositiveSmallIntegerField() + earth = models.PositiveSmallIntegerField() + air = models.PositiveSmallIntegerField() + time_el = models.PositiveSmallIntegerField() + space_el = models.PositiveSmallIntegerField() + + # Full chart payload + chart_data = models.JSONField() + + class Meta: + ordering = ['dt'] + + def elements_dict(self): + return { + 'Fire': self.fire, + 'Water': self.water, + 'Earth': self.earth, + 'Air': self.air, + 'Time': self.time_el, + 'Space': self.space_el, + } diff --git a/pyswiss/apps/charts/tests/__init__.py b/pyswiss/apps/charts/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyswiss/apps/charts/tests/integrated/__init__.py b/pyswiss/apps/charts/tests/integrated/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyswiss/apps/charts/tests/integrated/test_charts_list.py b/pyswiss/apps/charts/tests/integrated/test_charts_list.py new file mode 100644 index 0000000..60ea49b --- /dev/null +++ b/pyswiss/apps/charts/tests/integrated/test_charts_list.py @@ -0,0 +1,159 @@ +""" +Integration tests for GET /api/charts/ — ephemeris range/filter queries. + +These tests drive the EphemerisSnapshot model and list view. +Snapshots are created directly in setUp — no live ephemeris calc needed. + +Run: + pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts +""" +from django.test import TestCase + +from apps.charts.models import EphemerisSnapshot + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +CHART_DATA_STUB = { + 'planets': { + 'Sun': {'sign': 'Capricorn', 'degree': 280.37, 'retrograde': False}, + 'Moon': {'sign': 'Aries', 'degree': 15.2, 'retrograde': False}, + 'Mercury': {'sign': 'Capricorn', 'degree': 275.1, 'retrograde': False}, + 'Venus': {'sign': 'Sagittarius','degree': 250.3, 'retrograde': False}, + 'Mars': {'sign': 'Aquarius', 'degree': 308.6, 'retrograde': False}, + 'Jupiter': {'sign': 'Aries', 'degree': 25.9, 'retrograde': False}, + 'Saturn': {'sign': 'Taurus', 'degree': 40.5, 'retrograde': False}, + 'Uranus': {'sign': 'Aquarius', 'degree': 314.2, 'retrograde': False}, + 'Neptune': {'sign': 'Capricorn', 'degree': 303.8, 'retrograde': False}, + 'Pluto': {'sign': 'Sagittarius','degree': 248.4, 'retrograde': False}, + }, + 'houses': {'cusps': [0]*12, 'asc': 180.0, 'mc': 90.0}, +} + + +def make_snapshot(dt_str, fire=2, water=2, earth=3, air=2, time_el=1, space_el=3, + chart_data=None): + return EphemerisSnapshot.objects.create( + dt=dt_str, + fire=fire, water=water, earth=earth, air=air, + time_el=time_el, space_el=space_el, + chart_data=chart_data or CHART_DATA_STUB, + ) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class ChartsListApiTest(TestCase): + """GET /api/charts/ — query pre-computed ephemeris snapshots.""" + + def setUp(self): + make_snapshot('2000-01-01T12:00:00Z', fire=3, water=2, earth=3, air=2) + make_snapshot('2000-01-02T12:00:00Z', fire=1, water=4, earth=3, air=2) + make_snapshot('2000-01-03T12:00:00Z', fire=2, water=2, earth=4, air=2) + # Outside the usual date range — should not appear in filtered results + make_snapshot('2001-06-15T12:00:00Z', fire=4, water=1, earth=3, air=2) + + def _get(self, params=None): + return self.client.get('/api/charts/', params or {}) + + # ── guards ──────────────────────────────────────────────────────────── + + def test_charts_returns_400_if_date_from_missing(self): + response = self._get({'date_to': '2000-01-31'}) + self.assertEqual(response.status_code, 400) + + def test_charts_returns_400_if_date_to_missing(self): + response = self._get({'date_from': '2000-01-01'}) + self.assertEqual(response.status_code, 400) + + def test_charts_returns_400_for_invalid_date_from(self): + response = self._get({'date_from': 'not-a-date', 'date_to': '2000-01-31'}) + self.assertEqual(response.status_code, 400) + + def test_charts_returns_400_if_date_to_before_date_from(self): + response = self._get({'date_from': '2000-01-31', 'date_to': '2000-01-01'}) + self.assertEqual(response.status_code, 400) + + # ── response shape ──────────────────────────────────────────────────── + + def test_charts_returns_200_for_valid_params(self): + response = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}) + self.assertEqual(response.status_code, 200) + + def test_charts_response_is_json(self): + response = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}) + self.assertIn('application/json', response['Content-Type']) + + def test_charts_response_has_results_and_count(self): + data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json() + self.assertIn('results', data) + self.assertIn('count', data) + + def test_each_result_has_dt_and_elements(self): + data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json() + for result in data['results']: + with self.subTest(dt=result.get('dt')): + self.assertIn('dt', result) + self.assertIn('elements', result) + for key in ('Fire', 'Water', 'Earth', 'Air', 'Time', 'Space'): + self.assertIn(key, result['elements']) + + def test_each_result_has_planets(self): + data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json() + for result in data['results']: + with self.subTest(dt=result.get('dt')): + self.assertIn('planets', result) + + # ── date range filtering ────────────────────────────────────────────── + + def test_charts_returns_only_snapshots_in_date_range(self): + data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json() + self.assertEqual(data['count'], 3) + + def test_charts_count_matches_results_length(self): + data = self._get({'date_from': '2000-01-01', 'date_to': '2000-12-31'}).json() + self.assertEqual(data['count'], len(data['results'])) + + def test_charts_date_range_is_inclusive(self): + data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-01'}).json() + self.assertEqual(data['count'], 1) + + def test_charts_results_ordered_by_dt(self): + data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json() + dts = [r['dt'] for r in data['results']] + self.assertEqual(dts, sorted(dts)) + + # ── element range filtering ─────────────────────────────────────────── + + def test_charts_filters_by_fire_min(self): + # Only the Jan 1 snapshot has fire=3; Jan 2 has fire=1, Jan 3 has fire=2 + data = self._get({ + 'date_from': '2000-01-01', 'date_to': '2000-01-31', 'fire_min': 3, + }).json() + self.assertEqual(data['count'], 1) + + def test_charts_filters_by_water_min(self): + # Only the Jan 2 snapshot has water=4 + data = self._get({ + 'date_from': '2000-01-01', 'date_to': '2000-01-31', 'water_min': 4, + }).json() + self.assertEqual(data['count'], 1) + + def test_charts_filters_by_earth_min(self): + # Jan 3 has earth=4; Jan 1 and Jan 2 have earth=3 + data = self._get({ + 'date_from': '2000-01-01', 'date_to': '2000-01-31', 'earth_min': 4, + }).json() + self.assertEqual(data['count'], 1) + + def test_charts_multiple_element_filters_are_conjunctive(self): + # fire>=2 AND water>=2: Jan 1 (fire=3,water=2) + Jan 3 (fire=2,water=2); not Jan 2 (fire=1) + data = self._get({ + 'date_from': '2000-01-01', 'date_to': '2000-01-31', + 'fire_min': 2, 'water_min': 2, + }).json() + self.assertEqual(data['count'], 2) diff --git a/pyswiss/apps/charts/tests/integrated/test_views.py b/pyswiss/apps/charts/tests/integrated/test_views.py new file mode 100644 index 0000000..5982f01 --- /dev/null +++ b/pyswiss/apps/charts/tests/integrated/test_views.py @@ -0,0 +1,126 @@ +""" +Integration tests for the PySwiss chart calculation API. + +These tests drive the TDD implementation of GET /api/chart/. +They verify the HTTP contract using Django's test client. + +Run: + pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts +""" +from django.test import TestCase + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# J2000.0 — a well-known reference point: Sun at ~280.37° (Capricorn 10°22') +J2000 = '2000-01-01T12:00:00Z' +LONDON = {'lat': 51.5074, 'lon': -0.1278} + + +class ChartApiTest(TestCase): + """GET /api/chart/ — calculate a natal chart from datetime + coordinates.""" + + def _get(self, params): + return self.client.get('/api/chart/', params) + + # ── guards ──────────────────────────────────────────────────────────── + + def test_chart_returns_400_if_dt_missing(self): + response = self._get({'lat': 51.5074, 'lon': -0.1278}) + self.assertEqual(response.status_code, 400) + + def test_chart_returns_400_if_lat_missing(self): + response = self._get({'dt': J2000, 'lon': -0.1278}) + self.assertEqual(response.status_code, 400) + + def test_chart_returns_400_if_lon_missing(self): + response = self._get({'dt': J2000, 'lat': 51.5074}) + self.assertEqual(response.status_code, 400) + + def test_chart_returns_400_for_invalid_dt_format(self): + response = self._get({'dt': 'not-a-date', **LONDON}) + self.assertEqual(response.status_code, 400) + + def test_chart_returns_400_for_out_of_range_lat(self): + response = self._get({'dt': J2000, 'lat': 999, 'lon': -0.1278}) + self.assertEqual(response.status_code, 400) + + # ── response shape ──────────────────────────────────────────────────── + + def test_chart_returns_200_for_valid_params(self): + response = self._get({'dt': J2000, **LONDON}) + self.assertEqual(response.status_code, 200) + + def test_chart_response_is_json(self): + response = self._get({'dt': J2000, **LONDON}) + self.assertIn('application/json', response['Content-Type']) + + def test_chart_returns_all_ten_planets(self): + data = self._get({'dt': J2000, **LONDON}).json() + expected = { + 'Sun', 'Moon', 'Mercury', 'Venus', 'Mars', + 'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto', + } + self.assertEqual(set(data['planets'].keys()), expected) + + def test_each_planet_has_sign_degree_and_retrograde(self): + data = self._get({'dt': J2000, **LONDON}).json() + for name, planet in data['planets'].items(): + with self.subTest(planet=name): + self.assertIn('sign', planet) + self.assertIn('degree', planet) + self.assertIn('retrograde', planet) + + def test_chart_returns_houses(self): + data = self._get({'dt': J2000, **LONDON}).json() + houses = data['houses'] + self.assertEqual(len(houses['cusps']), 12) + self.assertIn('asc', houses) + self.assertIn('mc', houses) + + def test_chart_returns_six_element_counts(self): + """Fire/Water/Earth/Air are sign-based counts; Time/Space are emergent.""" + data = self._get({'dt': J2000, **LONDON}).json() + for key in ('Fire', 'Water', 'Earth', 'Air', 'Time', 'Space'): + with self.subTest(element=key): + self.assertIn(key, data['elements']) + + def test_chart_reports_active_house_system(self): + data = self._get({'dt': J2000, **LONDON}).json() + self.assertIn('house_system', data) + + # ── calculation correctness ─────────────────────────────────────────── + + def test_sun_is_in_capricorn_at_j2000(self): + """Regression: Sun at J2000.0 is ~280.37° — Capricorn.""" + data = self._get({'dt': J2000, **LONDON}).json() + sun = data['planets']['Sun'] + self.assertEqual(sun['sign'], 'Capricorn') + self.assertAlmostEqual(sun['degree'], 280.37, delta=0.1) + + def test_sun_is_not_retrograde(self): + """The Sun never goes retrograde.""" + data = self._get({'dt': J2000, **LONDON}).json() + self.assertFalse(data['planets']['Sun']['retrograde']) + + def test_element_counts_sum_to_ten(self): + """All 10 planets are assigned to exactly one classical element.""" + data = self._get({'dt': J2000, **LONDON}).json() + classical = sum( + data['elements'][e] for e in ('Fire', 'Water', 'Earth', 'Air') + ) + self.assertEqual(classical, 10) + + # ── house system ────────────────────────────────────────────────────── + + def test_default_house_system_is_porphyry(self): + """Porphyry ('O') is the project default — no param needed.""" + data = self._get({'dt': J2000, **LONDON}).json() + self.assertEqual(data['house_system'], 'O') + + def test_non_superuser_cannot_override_house_system(self): + """House system override is superuser-only; plain requests get 403.""" + response = self._get({'dt': J2000, **LONDON, 'house_system': 'P'}) + self.assertEqual(response.status_code, 403) diff --git a/pyswiss/apps/charts/tests/unit/__init__.py b/pyswiss/apps/charts/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyswiss/apps/charts/tests/unit/test_populate_ephemeris.py b/pyswiss/apps/charts/tests/unit/test_populate_ephemeris.py new file mode 100644 index 0000000..fb8a437 --- /dev/null +++ b/pyswiss/apps/charts/tests/unit/test_populate_ephemeris.py @@ -0,0 +1,99 @@ +""" +Unit tests for the populate_ephemeris management command. + +pyswisseph calls are mocked — these tests verify date iteration, +snapshot persistence, and idempotency without touching the ephemeris. + +Run: + pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts +""" +from datetime import datetime, timezone +from unittest.mock import patch + +from django.core.management import call_command +from django.test import TestCase + +from apps.charts.models import EphemerisSnapshot + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# 10 planets covering Fire×3, Earth×3, Air×2, Water×2 (one per sign) +# Expected: fire=3, water=2, earth=3, air=2, time=0, space=9 +FAKE_PLANETS = { + 'Sun': {'sign': 'Aries', 'degree': 10.0, 'retrograde': False}, + 'Moon': {'sign': 'Leo', 'degree': 130.0, 'retrograde': False}, + 'Mercury': {'sign': 'Sagittarius', 'degree': 250.0, 'retrograde': False}, + 'Venus': {'sign': 'Taurus', 'degree': 40.0, 'retrograde': False}, + 'Mars': {'sign': 'Virgo', 'degree': 160.0, 'retrograde': False}, + 'Jupiter': {'sign': 'Capricorn', 'degree': 280.0, 'retrograde': False}, + 'Saturn': {'sign': 'Gemini', 'degree': 70.0, 'retrograde': False}, + 'Uranus': {'sign': 'Aquarius', 'degree': 310.0, 'retrograde': False}, + 'Neptune': {'sign': 'Cancer', 'degree': 100.0, 'retrograde': False}, + 'Pluto': {'sign': 'Pisces', 'degree': 340.0, 'retrograde': False}, +} + +PATCH_TARGET = ( + 'apps.charts.management.commands.populate_ephemeris.get_planet_positions' +) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class PopulateEphemerisCommandTest(TestCase): + + def _run(self, date_from, date_to): + with patch(PATCH_TARGET, return_value=FAKE_PLANETS): + call_command('populate_ephemeris', + date_from=date_from, date_to=date_to, + verbosity=0) + + # ── date iteration ──────────────────────────────────────────────────── + + def test_creates_one_snapshot_per_day(self): + self._run('2000-01-01', '2000-01-03') + self.assertEqual(EphemerisSnapshot.objects.count(), 3) + + def test_single_day_range_creates_one_snapshot(self): + self._run('2000-01-01', '2000-01-01') + self.assertEqual(EphemerisSnapshot.objects.count(), 1) + + def test_snapshots_are_at_noon_utc(self): + self._run('2000-01-01', '2000-01-01') + snap = EphemerisSnapshot.objects.get() + self.assertEqual(snap.dt, datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc)) + + # ── idempotency ─────────────────────────────────────────────────────── + + def test_rerunning_does_not_create_duplicates(self): + self._run('2000-01-01', '2000-01-03') + self._run('2000-01-01', '2000-01-03') + self.assertEqual(EphemerisSnapshot.objects.count(), 3) + + def test_overlapping_ranges_do_not_duplicate(self): + self._run('2000-01-01', '2000-01-03') + self._run('2000-01-02', '2000-01-05') + self.assertEqual(EphemerisSnapshot.objects.count(), 5) + + # ── element counts ──────────────────────────────────────────────────── + + def test_element_counts_are_persisted(self): + self._run('2000-01-01', '2000-01-01') + snap = EphemerisSnapshot.objects.get() + self.assertEqual(snap.fire, 3) + self.assertEqual(snap.water, 2) + self.assertEqual(snap.earth, 3) + self.assertEqual(snap.air, 2) + self.assertEqual(snap.time_el, 0) + self.assertEqual(snap.space_el, 9) + + # ── chart_data payload ──────────────────────────────────────────────── + + def test_chart_data_contains_planets(self): + self._run('2000-01-01', '2000-01-01') + snap = EphemerisSnapshot.objects.get() + self.assertEqual(snap.chart_data['planets'], FAKE_PLANETS) diff --git a/pyswiss/apps/charts/urls.py b/pyswiss/apps/charts/urls.py new file mode 100644 index 0000000..78d1960 --- /dev/null +++ b/pyswiss/apps/charts/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('chart/', views.chart, name='chart'), + path('charts/', views.charts_list, name='charts_list'), +] diff --git a/pyswiss/apps/charts/views.py b/pyswiss/apps/charts/views.py new file mode 100644 index 0000000..d1d5a90 --- /dev/null +++ b/pyswiss/apps/charts/views.py @@ -0,0 +1,110 @@ +from datetime import datetime, timezone + +from django.http import HttpResponse, JsonResponse + +import swisseph as swe + +from .calc import ( + DEFAULT_HOUSE_SYSTEM, + get_element_counts, + get_julian_day, + get_planet_positions, + set_ephe_path, +) +from .models import EphemerisSnapshot + + +def chart(request): + dt_str = request.GET.get('dt') + lat_str = request.GET.get('lat') + lon_str = request.GET.get('lon') + + if not dt_str or lat_str is None or lon_str is None: + return HttpResponse(status=400) + + try: + dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00')) + except ValueError: + return HttpResponse(status=400) + + try: + lat = float(lat_str) + lon = float(lon_str) + except ValueError: + return HttpResponse(status=400) + + if not (-90 <= lat <= 90): + return HttpResponse(status=400) + + house_system_param = request.GET.get('house_system') + if house_system_param is not None: + if not (hasattr(request, 'user') and request.user.is_authenticated + and request.user.is_superuser): + return HttpResponse(status=403) + house_system = house_system_param + else: + house_system = DEFAULT_HOUSE_SYSTEM + + set_ephe_path() + + jd = get_julian_day(dt) + planets = get_planet_positions(jd) + + cusps, ascmc = swe.houses(jd, lat, lon, house_system.encode()) + houses = { + 'cusps': list(cusps), + 'asc': ascmc[0], + 'mc': ascmc[1], + } + + return JsonResponse({ + 'planets': planets, + 'houses': houses, + 'elements': get_element_counts(planets), + 'house_system': house_system, + }) + + +def charts_list(request): + date_from_str = request.GET.get('date_from') + date_to_str = request.GET.get('date_to') + + if not date_from_str or not date_to_str: + return HttpResponse(status=400) + + try: + date_from = datetime.strptime(date_from_str, '%Y-%m-%d').replace( + tzinfo=timezone.utc) + date_to = datetime.strptime(date_to_str, '%Y-%m-%d').replace( + hour=23, minute=59, second=59, tzinfo=timezone.utc) + except ValueError: + return HttpResponse(status=400) + + if date_to < date_from: + return HttpResponse(status=400) + + qs = EphemerisSnapshot.objects.filter(dt__gte=date_from, dt__lte=date_to) + + element_fields = { + 'fire_min': 'fire', 'water_min': 'water', + 'earth_min': 'earth', 'air_min': 'air', + 'time_min': 'time_el', 'space_min': 'space_el', + } + for param, field in element_fields.items(): + value = request.GET.get(param) + if value is not None: + try: + qs = qs.filter(**{f'{field}__gte': int(value)}) + except ValueError: + return HttpResponse(status=400) + + results = [ + { + 'dt': snap.dt.isoformat(), + 'elements': snap.elements_dict(), + 'planets': snap.chart_data.get('planets', {}), + } + for snap in qs + ] + + return JsonResponse({'results': results, 'count': len(results)}) diff --git a/pyswiss/core/__init__.py b/pyswiss/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyswiss/core/settings.py b/pyswiss/core/settings.py new file mode 100644 index 0000000..147828a --- /dev/null +++ b/pyswiss/core/settings.py @@ -0,0 +1,38 @@ +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = 'pyswiss-dev-only-key-replace-in-production' +DEBUG = True +ALLOWED_HOSTS = ['*'] + +INSTALLED_APPS = [ + 'django.contrib.contenttypes', + 'django.contrib.auth', + 'apps.charts', +] + +ROOT_URLCONF = 'core.urls' + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +STATIC_URL = '/static/' +MEDIA_URL = '/media/' + +USE_TZ = True +TIME_ZONE = 'UTC' + +# Swiss Ephemeris data files. +# Override via SWISSEPH_PATH env var on staging/production. +SWISSEPH_PATH = os.environ.get( + 'SWISSEPH_PATH', + r'D:\OneDrive\Desktop\potentium\implicateOrder\libraries\swisseph-master\ephe', +) diff --git a/pyswiss/core/urls.py b/pyswiss/core/urls.py new file mode 100644 index 0000000..f399974 --- /dev/null +++ b/pyswiss/core/urls.py @@ -0,0 +1,5 @@ +from django.urls import path, include + +urlpatterns = [ + path('api/', include('apps.charts.urls')), +] diff --git a/pyswiss/manage.py b/pyswiss/manage.py new file mode 100644 index 0000000..4e3556f --- /dev/null +++ b/pyswiss/manage.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python +import os +import sys + + +def main(): + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and available " + "on your PYTHONPATH environment variable? Did you forget to activate " + "a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/pyswiss/requirements.txt b/pyswiss/requirements.txt new file mode 100644 index 0000000..619c8e3 --- /dev/null +++ b/pyswiss/requirements.txt @@ -0,0 +1,2 @@ +django==6.0.4 +pyswisseph==2.10.3.2