diff --git a/hr_birth_astral_chart/README.rst b/hr_birth_astral_chart/README.rst new file mode 100644 index 00000000000..69a8458d9c7 --- /dev/null +++ b/hr_birth_astral_chart/README.rst @@ -0,0 +1,105 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +===================== +HR Birth Astral Chart +===================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:7cdfdfdcb177bcfdbc83acf2f37349036ea8006643bfc524ffc77d8ba08c5b8a + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fhr-lightgray.png?logo=github + :target: https://github.com/OCA/hr/tree/19.0/hr_birth_astral_chart + :alt: OCA/hr +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/hr-19-0/hr-19-0-hr_birth_astral_chart + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/hr&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +View your full Western astrological birth chart directly from your +employee profile. + +Depends on ``hr_birth_data`` for the birth time and coordinates fields. + +Adds an **Astral Chart** tab to the employee form showing: + +- **Sun Sign**, **Moon Sign** and **Ascendant** summary badges +- An SVG zodiac wheel with planetary positions +- A detailed table with degree, minute, sign and house for each planet + (Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn, Uranus, Neptune, + Pluto, Chiron, Black Moon Lilith, Ceres) +- Astrological houses (Whole Sign system) when birth time and location + are provided +- Current transits biwheel and interpretation + +Astronomical calculations use ``pyswisseph`` (Python binding for Swiss +Ephemeris) for high-precision planetary positions. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* ForgeFlow + +Contributors +------------ + +- Miquel Raïch + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-MiquelRForgeFlow| image:: https://github.com/MiquelRForgeFlow.png?size=40px + :target: https://github.com/MiquelRForgeFlow + :alt: MiquelRForgeFlow + +Current `maintainer `__: + +|maintainer-MiquelRForgeFlow| + +This module is part of the `OCA/hr `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/hr_birth_astral_chart/__init__.py b/hr_birth_astral_chart/__init__.py new file mode 100644 index 00000000000..34d939457c3 --- /dev/null +++ b/hr_birth_astral_chart/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Forgeflow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import models diff --git a/hr_birth_astral_chart/__manifest__.py b/hr_birth_astral_chart/__manifest__.py new file mode 100644 index 00000000000..39bcb4af4cb --- /dev/null +++ b/hr_birth_astral_chart/__manifest__.py @@ -0,0 +1,26 @@ +# Copyright 2026 Forgeflow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +{ + "name": "HR Birth Astral Chart", + "version": "19.0.1.0.0", + "category": "Human Resources", + "website": "https://github.com/OCA/hr", + "author": "ForgeFlow, Odoo Community Association (OCA)", + "maintainers": ["MiquelRForgeFlow"], + "license": "AGPL-3", + "installable": True, + "application": False, + "summary": "View your full astrological birth chart from your employee profile", + "external_dependencies": {"python": ["pyswisseph"]}, + "depends": ["hr_birth_data"], + "data": [ + "views/hr_employee_views.xml", + ], + "assets": { + "web.assets_backend": [ + "hr_birth_astral_chart/static/src/xml/birth_chart_table.xml", + "hr_birth_astral_chart/static/src/js/birth_chart_table.esm.js", + ], + }, +} diff --git a/hr_birth_astral_chart/ephe/seas_18.se1 b/hr_birth_astral_chart/ephe/seas_18.se1 new file mode 100644 index 00000000000..5e75e5659ed Binary files /dev/null and b/hr_birth_astral_chart/ephe/seas_18.se1 differ diff --git a/hr_birth_astral_chart/models/__init__.py b/hr_birth_astral_chart/models/__init__.py new file mode 100644 index 00000000000..78d1aa7a714 --- /dev/null +++ b/hr_birth_astral_chart/models/__init__.py @@ -0,0 +1,6 @@ +# Copyright 2026 Forgeflow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import astro_calc +from . import interpretations +from . import hr_employee diff --git a/hr_birth_astral_chart/models/astro_calc.py b/hr_birth_astral_chart/models/astro_calc.py new file mode 100644 index 00000000000..d2b2f644cda --- /dev/null +++ b/hr_birth_astral_chart/models/astro_calc.py @@ -0,0 +1,171 @@ +# Copyright 2026 Forgeflow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +# pylint: disable=W8161 + +import os + +import swisseph as swe + +_EPHE_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "ephe") +swe.set_ephe_path(_EPHE_PATH) + +SIGNS = [ + "Aries", + "Taurus", + "Gemini", + "Cancer", + "Leo", + "Virgo", + "Libra", + "Scorpio", + "Sagittarius", + "Capricorn", + "Aquarius", + "Pisces", +] +SIGN_SYMBOLS = ["♈", "♉", "♊", "♋", "♌", "♍", "♎", "♏", "♐", "♑", "♒", "♓"] +# Keep as plain English — used as dictionary keys in interpretation logic +SIGN_ELEMENTS = ["Fire", "Earth", "Air", "Water"] * 3 +SIGN_MODALITIES = ["Cardinal", "Fixed", "Mutable"] * 4 +SIGN_POLARITIES = ["Positive", "Negative"] * 6 + +PLANET_NAMES = [ + "Sun", + "Moon", + "Mercury", + "Venus", + "Mars", + "Jupiter", + "Saturn", + "Uranus", + "Neptune", + "Pluto", + "Chiron", + "Lilith", + "Ceres", +] +PLANET_SYMBOLS = ["☉", "☽", "☿", "♀", "♂", "♃", "♄", "⛢", "♆", "♇", "⚷", "⚸", "⚳"] +PLANET_KEYS = [ + "sun", + "moon", + "mercury", + "venus", + "mars", + "jupiter", + "saturn", + "uranus", + "neptune", + "pluto", + "chiron", + "lilith", + "ceres", +] + +_SWE_IDS = { + "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, + "chiron": swe.CHIRON, + "lilith": swe.MEAN_APOG, + "ceres": swe.CERES, +} + +# Chiron and Ceres require seas_18.se1; all others work via Moshier fallback. +_NEEDS_SE_FILE = {"chiron", "ceres"} +_FLAGS_SE = swe.FLG_SWIEPH +_FLAGS_MOSH = swe.FLG_MOSEPH + + +def _norm(deg): + return deg % 360.0 + + +def lon_to_sign(lon): + """Return (sign_index, degrees_in_sign, minutes).""" + idx = int(lon / 30) % 12 + deg_in = lon % 30 + return idx, int(deg_in), int((deg_in % 1) * 60) + + +def get_house(lon, houses): + """Return 1-based house number for a longitude given Whole Sign houses.""" + if not houses: + return None + for h_i in range(12): + cusp_start = houses[h_i] + cusp_end = houses[(h_i + 1) % 12] + if cusp_start <= cusp_end: + in_house = cusp_start <= lon < cusp_end + else: + in_house = lon >= cusp_start or lon < cusp_end + if in_house: + return h_i + 1 + return None + + +ASPECT_DEFS = [ + ("Conjunction", 0, 8, "☌", "#cc3333"), + ("Sextile", 60, 5, "⚹", "#44aa44"), + ("Square", 90, 7, "□", "#cc6633"), + ("Trine", 120, 7, "△", "#4466cc"), + ("Opposition", 180, 8, "☍", "#aa3399"), +] + + +def calc_aspects(natal_planets, transit_planets): + """Return list of active aspects between transit and natal planets.""" + aspects = [] + for t_key in PLANET_KEYS: + t_lon = transit_planets[t_key] + for n_key in PLANET_KEYS: + n_lon = natal_planets[n_key] + diff = abs((t_lon - n_lon + 180) % 360 - 180) + for name, angle, orb, symbol, color in ASPECT_DEFS: + if abs(diff - angle) <= orb: + aspects.append( + { + "transit_key": t_key, + "natal_key": n_key, + "aspect": name, + "symbol": symbol, + "color": color, + "orb": round(abs(diff - angle), 1), + } + ) + return aspects + + +def compute_chart(year, month, day, hour=12.0, lat=None, lon=None): + """Compute full birth chart using Swiss Ephemeris (Moshier fallback).""" + swe.set_ephe_path(_EPHE_PATH) + jd = swe.julday(year, month, day, hour) + + planets = {} + for key in PLANET_KEYS: + flags = _FLAGS_SE if key in _NEEDS_SE_FILE else _FLAGS_MOSH + result, _ = swe.calc_ut(jd, _SWE_IDS[key], flags) + planets[key] = _norm(result[0]) + + nn_result, _ = swe.calc_ut(jd, swe.MEAN_NODE, _FLAGS_MOSH) + north_node = _norm(nn_result[0]) + + chart = {"planets": planets, "jd": jd, "north_node": north_node} + + if lat is not None and lon is not None: + cusps, ascmc = swe.houses(jd, lat, lon, b"W") + chart["ascendant"] = _norm(ascmc[0]) + chart["midheaven"] = _norm(ascmc[1]) + chart["houses"] = [_norm(c) for c in cusps[0:12]] + else: + chart["ascendant"] = None + chart["midheaven"] = None + chart["houses"] = None + + return chart diff --git a/hr_birth_astral_chart/models/chart_svg.py b/hr_birth_astral_chart/models/chart_svg.py new file mode 100644 index 00000000000..2ce6eebad55 --- /dev/null +++ b/hr_birth_astral_chart/models/chart_svg.py @@ -0,0 +1,502 @@ +# Copyright 2026 Forgeflow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +# pylint: disable=W8161 + +import math + +from .astro_calc import ( + PLANET_KEYS, + PLANET_NAMES, + PLANET_SYMBOLS, + SIGN_SYMBOLS, + SIGNS, + get_house, + lon_to_sign, +) + +CX, CY, R = 260, 260, 200 +R_SIGN = R +R_SIGN_IN = R - 36 +R_HOUSE = R_SIGN_IN - 4 +R_PLANET = R_HOUSE - 22 +R_CENTER = R_PLANET - 28 + +R_TRANSIT_OUT = R_SIGN + 36 +R_TRANSIT_IN = R_SIGN + 4 +R_TRANSIT_PLANET = R_SIGN + 20 + +COLOR_FIRE = "#e8554e" +COLOR_EARTH = "#7db87d" +COLOR_AIR = "#7ba7c7" +COLOR_WATER = "#9b7fc0" +SIGN_COLORS = [COLOR_FIRE, COLOR_EARTH, COLOR_AIR, COLOR_WATER] * 3 +PLANET_COLORS = { + "sun": "#e8a020", + "moon": "#8888cc", + "mercury": "#44aa88", + "venus": "#cc6688", + "mars": "#cc3333", + "jupiter": "#8866cc", + "saturn": "#888844", + "uranus": "#44aacc", + "neptune": "#4466cc", + "pluto": "#884422", + "chiron": "#5fa8a0", + "lilith": "#776688", + "ceres": "#6a9e5a", +} + +# CSS embedded in the SVG — uses Bootstrap/Odoo CSS variables so the chart +# adapts automatically to light mode, dark mode and custom themes. +_SVG_STYLE = """""" + + +def _r(deg): + return math.radians(deg) + + +def _xy(angle_deg, r, cx=CX, cy=CY): + a = math.radians(180.0 - angle_deg) + return cx + r * math.cos(a), cy + r * math.sin(a) + + +def _sign_sector(i): + start, end = i * 30, (i + 1) * 30 + x1o, y1o = _xy(start, R_SIGN) + x2o, y2o = _xy(end, R_SIGN) + x1i, y1i = _xy(start, R_SIGN_IN) + x2i, y2i = _xy(end, R_SIGN_IN) + return ( + f"M {x1o:.2f} {y1o:.2f} " + f"A {R_SIGN} {R_SIGN} 0 0 0 {x2o:.2f} {y2o:.2f} " + f"L {x2i:.2f} {y2i:.2f} " + f"A {R_SIGN_IN} {R_SIGN_IN} 0 0 1 {x1i:.2f} {y1i:.2f} Z" + ) + + +def _spoke(angle_deg): + x1, y1 = _xy(angle_deg, R_SIGN) + x2, y2 = _xy(angle_deg, R_SIGN_IN) + return f"M {x1:.2f} {y1:.2f} L {x2:.2f} {y2:.2f}" + + +def _render_zodiac_ring(parts, r_out, r_in, xy_fn, opacity, font_size): + """Render a 12-sector zodiac ring with sign symbols and boundary spokes.""" + for i in range(12): + s, e = i * 30, (i + 1) * 30 + x1o, y1o = xy_fn(s, r_out) + x2o, y2o = xy_fn(e, r_out) + x1i, y1i = xy_fn(s, r_in) + x2i, y2i = xy_fn(e, r_in) + d = ( + f"M {x1o:.2f} {y1o:.2f} " + f"A {r_out} {r_out} 0 0 0 {x2o:.2f} {y2o:.2f} " + f"L {x2i:.2f} {y2i:.2f} " + f"A {r_in} {r_in} 0 0 1 {x1i:.2f} {y1i:.2f} Z" + ) + parts.append( + f'' + ) + mid = i * 30 + 15 + sx, sy = xy_fn(mid, (r_out + r_in) / 2) + parts.append( + f'{SIGN_SYMBOLS[i]}' + ) + for i in range(12): + x1, y1 = xy_fn(i * 30, r_out) + x2, y2 = xy_fn(i * 30, r_in) + parts.append( + f'' + ) + + +def _place_planets(planets, planet_keys, min_gap_deg): + """Return list of (adjusted_angle, key) with overlap avoidance.""" + placed = [] + for key in planet_keys: + adj = planets[key] + for prev_adj, _k in placed: + if abs((adj - prev_adj + 180) % 360 - 180) < min_gap_deg: + adj = prev_adj + min_gap_deg + 1 + placed.append((adj, key)) + return placed + + +def _render_planet_ring( + parts, + placed, + planets, + tick_r, + planet_r, + colors, + symbols, + xy_fn, + font_size, + inner_tick=True, +): + """Render planet tick marks, offset leader lines, and symbol labels.""" + for adj, key in placed: + orig = planets[key] + col = colors[key] + sym = symbols[PLANET_KEYS.index(key)] + px, py = xy_fn(adj, planet_r) + tx1, ty1 = xy_fn(orig, tick_r - 2) + tx2, ty2 = xy_fn(orig, tick_r + 2) + parts.append( + f'' + ) + if abs((adj - orig + 180) % 360 - 180) > 2: + lx, ly = (tx1, ty1) if inner_tick else (tx2, ty2) + parts.append( + f'' + ) + parts.append( + f'{sym}' + ) + + +def generate_chart_svg(chart_data): + """Generate an SVG birth chart from compute_chart() output.""" + planets = chart_data["planets"] + houses = chart_data.get("houses") + asc = chart_data.get("ascendant") + mc = chart_data.get("midheaven") + + W, H = 520, 520 + parts = [ + f'', + _SVG_STYLE, + ] + + # ── Zodiac sectors ────────────────────────────────────────────────────── + for i in range(12): + parts.append( + f'' + ) + mid = i * 30 + 15 + sx, sy = _xy(mid, (R_SIGN + R_SIGN_IN) / 2) + parts.append( + f'{SIGN_SYMBOLS[i]}' + ) + + # Spokes between signs + for i in range(12): + parts.append(f'') + + # ── Ring circles ──────────────────────────────────────────────────────── + for rr, sw in ((R_SIGN, "1"), (R_SIGN_IN, ".8"), (R_HOUSE, ".5")): + parts.append( + f'' + ) + # Centre fill adapts to body background + parts.append( + f'' + ) + + # ── House cusps ───────────────────────────────────────────────────────── + if houses: + for i, cusp in enumerate(houses): + x1, y1 = _xy(cusp, R_HOUSE) + x2, y2 = _xy(cusp, R_CENTER) + cls = "bc-ac" if i == 0 else "bc-hl" + sw = "1.5" if i in (0, 3, 6, 9) else "0.5" + parts.append( + f'' + ) + mid_angle = cusp + 15 + nx, ny = _xy(mid_angle, (R_HOUSE + R_CENTER) / 2) + parts.append( + f'{i + 1}' + ) + + # ── Planets ────────────────────────────────────────────────────────────── + placed = _place_planets(planets, PLANET_KEYS, 12) + _render_planet_ring( + parts, + placed, + planets, + R_HOUSE, + R_PLANET, + PLANET_COLORS, + PLANET_SYMBOLS, + _xy, + 14, + ) + + # ── AC / MC labels ──────────────────────────────────────────────────────── + if asc is not None: + ax, ay = _xy(asc, R_HOUSE + 10) + parts.append(f'AC') + if mc is not None: + mx2, my2 = _xy(mc, R_HOUSE + 10) + parts.append(f'MC') + + parts.append("") + return "".join(parts) + + +def build_planet_table(env, chart_data): + """Return a list of dicts for the planet position table.""" + planets = chart_data["planets"] + houses = chart_data.get("houses") + rows = [] + for key, name, sym in zip(PLANET_KEYS, PLANET_NAMES, PLANET_SYMBOLS, strict=False): + lon = planets[key] + sign_i, deg, minute = lon_to_sign(lon) + rows.append( + { + "key": key, + "name": env._(name), + "symbol": sym, + "longitude": round(lon, 2), + "sign_index": sign_i, + "sign": env._(SIGNS[sign_i]), + "sign_symbol": SIGN_SYMBOLS[sign_i], + "position": f"{deg}°{minute:02d}'", + "house": get_house(lon, houses), + } + ) + + extra = [] + if chart_data.get("ascendant") is not None: + asc_lon = chart_data["ascendant"] + sign_i, deg, minute = lon_to_sign(asc_lon) + extra.append( + { + "key": "ascendant", + "name": env._("Ascendant"), + "symbol": "AC", + "longitude": round(asc_lon, 2), + "sign_index": sign_i, + "sign": env._(SIGNS[sign_i]), + "sign_symbol": SIGN_SYMBOLS[sign_i], + "position": f"{deg}°{minute:02d}'", + "house": 1, + } + ) + if chart_data.get("midheaven") is not None: + mc_lon = chart_data["midheaven"] + sign_i, deg, minute = lon_to_sign(mc_lon) + extra.append( + { + "key": "midheaven", + "name": env._("Midheaven (MC)"), + "symbol": "MC", + "longitude": round(mc_lon, 2), + "sign_index": sign_i, + "sign": env._(SIGNS[sign_i]), + "sign_symbol": SIGN_SYMBOLS[sign_i], + "position": f"{deg}°{minute:02d}'", + "house": 10, + } + ) + if chart_data.get("north_node") is not None: + nn_lon = chart_data["north_node"] + sign_i, deg, minute = lon_to_sign(nn_lon) + extra.append( + { + "key": "north_node", + "name": env._("North Node"), + "symbol": "☊", + "longitude": round(nn_lon, 2), + "sign_index": sign_i, + "sign": env._(SIGNS[sign_i]), + "sign_symbol": SIGN_SYMBOLS[sign_i], + "position": f"{deg}°{minute:02d}'", + "house": get_house(nn_lon, houses), + } + ) + return rows, extra + + +def build_houses_html(env, chart_data): + """Return an HTML table of houses (Whole Sign), or empty string if no houses.""" + houses = chart_data.get("houses") + if not houses: + return "" + planets = chart_data.get("planets", {}) + + # Map each house number → list of (symbol, color) for bodies in that house + house_bodies = {h: [] for h in range(1, 13)} + for key, sym in zip(PLANET_KEYS, PLANET_SYMBOLS, strict=False): + if key in planets: + h = get_house(planets[key], houses) + if h: + house_bodies[h].append((sym, PLANET_COLORS[key])) + if chart_data.get("north_node") is not None: + h = get_house(chart_data["north_node"], houses) + if h: + house_bodies[h].append(("☊", "#888888")) + + rows = [] + for i, cusp in enumerate(houses): + sign_i = int(cusp / 30) % 12 + bodies_html = "".join( + f'{sym}' + for sym, col in house_bodies[i + 1] + ) + rows.append( + f"" + f"{i + 1}" + f"" + f"" + f"{SIGN_SYMBOLS[sign_i]}" + f"{env._(SIGNS[sign_i])}" + f"{bodies_html}" + f"" + ) + return ( + "" + "" + f"" + f"" + f"" + "" + "" + "".join(rows) + "" + "
{env._('House')}{env._('Sign')}{env._('Planets')}
" + ) + + +def generate_biwheel_svg(natal_data, transit_data, aspects=None): + """Generate a bi-wheel SVG with natal (inner) and transit (outer) planets.""" + planets_n = natal_data["planets"] + houses = natal_data.get("houses") + asc = natal_data.get("ascendant") + mc = natal_data.get("midheaven") + planets_t = transit_data["planets"] + + W, H, CXB, CYB = 560, 560, 280, 280 + + def xy(angle_deg, r): + return _xy(angle_deg, r, cx=CXB, cy=CYB) + + parts = [ + f'', + _SVG_STYLE, + ] + + # ── Zodiac rings ────────────────────────────────────────────────────────── + _render_zodiac_ring(parts, R_TRANSIT_OUT, R_TRANSIT_IN, xy, "0.15", 10) + _render_zodiac_ring(parts, R_SIGN, R_SIGN_IN, xy, "0.30", 13) + + # ── Ring circles ───────────────────────────────────────────────────────── + for rr, sw in ( + (R_TRANSIT_OUT, "1"), + (R_TRANSIT_IN, ".8"), + (R_SIGN, "1"), + (R_SIGN_IN, ".8"), + (R_HOUSE, ".5"), + ): + parts.append( + f'' + ) + parts.append( + f'' + ) + + # ── House cusps ────────────────────────────────────────────────────────── + if houses: + for i, cusp in enumerate(houses): + x1, y1 = xy(cusp, R_HOUSE) + x2, y2 = xy(cusp, R_CENTER) + cls = "bc-ac" if i == 0 else "bc-hl" + sw = "1.5" if i in (0, 3, 6, 9) else "0.5" + parts.append( + f'' + ) + mid_angle = cusp + 15 + nx, ny = xy(mid_angle, (R_HOUSE + R_CENTER) / 2) + parts.append( + f'{i + 1}' + ) + + # ── Natal and transit planets ───────────────────────────────────────────── + placed_n = _place_planets(planets_n, PLANET_KEYS, 12) + _render_planet_ring( + parts, + placed_n, + planets_n, + R_HOUSE, + R_PLANET, + PLANET_COLORS, + PLANET_SYMBOLS, + xy, + 14, + ) + + placed_t = _place_planets(planets_t, PLANET_KEYS, 10) + _render_planet_ring( + parts, + placed_t, + planets_t, + R_TRANSIT_IN, + R_TRANSIT_PLANET, + PLANET_COLORS, + PLANET_SYMBOLS, + xy, + 12, + inner_tick=False, + ) + + # ── AC / MC labels ─────────────────────────────────────────────────────── + if asc is not None: + ax, ay = xy(asc, R_HOUSE + 10) + parts.append(f'AC') + if mc is not None: + mx2, my2 = xy(mc, R_HOUSE + 10) + parts.append(f'MC') + + # ── Aspect lines (tightest aspects only) ───────────────────────────────── + if aspects: + for asp in sorted(aspects, key=lambda a: a["orb"]): + if asp["orb"] > 4: + break + n_lon = planets_n[asp["natal_key"]] + t_lon = planets_t[asp["transit_key"]] + nxp, nyp = xy(n_lon, R_CENTER - 4) + txp, typ = xy(t_lon, R_TRANSIT_IN + 4) + opacity = round(max(0.1, 0.45 - asp["orb"] * 0.08), 2) + parts.append( + f'' + ) + + parts.append("") + return "".join(parts) diff --git a/hr_birth_astral_chart/models/hr_employee.py b/hr_birth_astral_chart/models/hr_employee.py new file mode 100644 index 00000000000..baba567e737 --- /dev/null +++ b/hr_birth_astral_chart/models/hr_employee.py @@ -0,0 +1,169 @@ +# Copyright 2026 Forgeflow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import json +from datetime import date as date_type + +from odoo import api, fields, models + +from .astro_calc import SIGN_SYMBOLS, SIGNS, calc_aspects, compute_chart, lon_to_sign +from .chart_svg import ( + build_houses_html, + build_planet_table, + generate_biwheel_svg, + generate_chart_svg, +) +from .interpretations import build_interpretation, build_transit_interpretation + + +class HrEmployee(models.Model): + _inherit = "hr.employee" + + # ── Computed chart fields ───────────────────────────────────────────── + birth_chart_svg = fields.Html( + string="Birth Chart", + compute="_compute_birth_chart", + sanitize=False, + ) + birth_chart_planets_json = fields.Char( + compute="_compute_birth_chart", + ) + birth_chart_sun_sign = fields.Char( + string="Sun Sign", + compute="_compute_birth_chart", + ) + birth_chart_moon_sign = fields.Char( + string="Moon Sign", + compute="_compute_birth_chart", + ) + birth_chart_rising_sign = fields.Char( + string="Ascendant", + compute="_compute_birth_chart", + ) + birth_chart_available = fields.Boolean( + compute="_compute_birth_chart", + ) + birth_chart_interpretation = fields.Html( + string="Chart Interpretation", + compute="_compute_birth_chart", + sanitize=False, + ) + birth_chart_houses_html = fields.Html( + string="Houses", + compute="_compute_birth_chart", + sanitize=False, + ) + + # ── Transit chart (recomputes on every page load — not stored) ──────────── + birth_chart_transit_svg = fields.Html( + string="Transit Chart", + compute="_compute_transit_chart", + sanitize=False, + ) + birth_chart_transit_json = fields.Char( + compute="_compute_transit_chart", + ) + birth_chart_transit_aspects = fields.Html( + string="Transit Aspects", + compute="_compute_transit_chart", + sanitize=False, + ) + birth_chart_transit_interpretation = fields.Html( + string="Transit Interpretation", + compute="_compute_transit_chart", + sanitize=False, + ) + + @api.depends("birthday", "birth_hour", "birth_latitude", "birth_longitude") + def _compute_birth_chart(self): + for rec in self: + if not rec.birthday: + rec.birth_chart_svg = False + rec.birth_chart_planets_json = False + rec.birth_chart_sun_sign = False + rec.birth_chart_moon_sign = False + rec.birth_chart_rising_sign = False + rec.birth_chart_interpretation = False + rec.birth_chart_houses_html = False + rec.birth_chart_available = False + continue + + bd = rec.birthday + lat = rec.birth_latitude or None + lon = rec.birth_longitude or None + if not (lat or lon): + lat = lon = None + + chart = compute_chart( + bd.year, + bd.month, + bd.day, + hour=rec.birth_hour or 12.0, + lat=lat, + lon=lon, + ) + + rec.birth_chart_svg = generate_chart_svg(chart) + rec.birth_chart_available = True + + sun_i, sun_d, sun_m = lon_to_sign(chart["planets"]["sun"]) + moon_i, moon_d, moon_m = lon_to_sign(chart["planets"]["moon"]) + rec.birth_chart_sun_sign = ( + f"{SIGN_SYMBOLS[sun_i]} {self.env._(SIGNS[sun_i])} {sun_d}° {sun_m}'" + ) + rec.birth_chart_moon_sign = ( + f"{SIGN_SYMBOLS[moon_i]}" + f" {self.env._(SIGNS[moon_i])} {moon_d}° {moon_m}'" + ) + + if chart["ascendant"] is not None: + asc_i, asc_d, asc_m = lon_to_sign(chart["ascendant"]) + rec.birth_chart_rising_sign = ( + f"{SIGN_SYMBOLS[asc_i]}" + f" {self.env._(SIGNS[asc_i])} {asc_d}° {asc_m}'" + ) + else: + rec.birth_chart_rising_sign = self.env._( + "Requires birth time and location" + ) + + rows, extra = build_planet_table(self.env, chart) + rec.birth_chart_planets_json = json.dumps(rows + extra) + rec.birth_chart_interpretation = build_interpretation(self.env, chart) + rec.birth_chart_houses_html = build_houses_html(self.env, chart) or False + + @api.depends("birthday", "birth_hour", "birth_latitude", "birth_longitude") + def _compute_transit_chart(self): + today = date_type.today() + transit = compute_chart(today.year, today.month, today.day, hour=12.0) + for rec in self: + if not rec.birthday: + rec.birth_chart_transit_svg = False + rec.birth_chart_transit_json = False + rec.birth_chart_transit_aspects = False + rec.birth_chart_transit_interpretation = False + continue + + bd = rec.birthday + lat = rec.birth_latitude or None + lon = rec.birth_longitude or None + if not (lat or lon): + lat = lon = None + + natal = compute_chart( + bd.year, + bd.month, + bd.day, + hour=rec.birth_hour or 12.0, + lat=lat, + lon=lon, + ) + aspects = calc_aspects(natal["planets"], transit["planets"]) + rec.birth_chart_transit_svg = generate_biwheel_svg(natal, transit, aspects) + rows, _extra = build_planet_table(self.env, transit) + rec.birth_chart_transit_json = json.dumps(rows) + aspects_html, interp_html = build_transit_interpretation( + self.env, natal, transit, aspects, today + ) + rec.birth_chart_transit_aspects = aspects_html or False + rec.birth_chart_transit_interpretation = interp_html or False diff --git a/hr_birth_astral_chart/models/interpretations.py b/hr_birth_astral_chart/models/interpretations.py new file mode 100644 index 00000000000..ace2687db84 --- /dev/null +++ b/hr_birth_astral_chart/models/interpretations.py @@ -0,0 +1,734 @@ +# Copyright 2026 Forgeflow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from .astro_calc import ( + PLANET_KEYS, + SIGN_ELEMENTS, + SIGN_MODALITIES, + SIGN_POLARITIES, + SIGN_SYMBOLS, + SIGNS, + get_house, +) + +SUN_INTERPRETATIONS = [ + # Aries + ( + "Bold, pioneering and energetic. A natural leader with a competitive spirit " + "and a strong drive to initiate new projects." + ), + # Taurus + ( + "Reliable, patient and practical. Values stability, beauty and material " + "comfort; known for persistence and sensuality." + ), + # Gemini + ( + "Curious, adaptable and communicative. Quick-witted and versatile, with a " + "constant need for intellectual stimulation." + ), + # Cancer + ( + "Intuitive, nurturing and protective. Deeply empathetic and family-oriented, " + "with a strong emotional memory." + ), + # Leo + ( + "Confident, creative and generous. A natural performer who thrives in the " + "spotlight; loyal and warm-hearted." + ), + # Virgo + ( + "Analytical, diligent and precise. Pays close attention to detail and has a " + "strong desire to be of service." + ), + # Libra + ( + "Diplomatic, fair-minded and social. Seeks harmony and balance; values " + "partnerships and aesthetic beauty." + ), + # Scorpio + ( + "Intense, perceptive and determined. Possesses deep emotional power and a " + "gift for transformation." + ), + # Sagittarius + ( + "Optimistic, adventurous and philosophical. Loves freedom, travel and the " + "pursuit of truth and knowledge." + ), + # Capricorn + ( + "Disciplined, ambitious and responsible. Patient and strategic, with a strong " + "drive for achievement." + ), + # Aquarius + ( + "Original, idealistic and independent. A forward-thinking humanitarian who " + "values friendship and innovation." + ), + # Pisces + ( + "Compassionate, imaginative and intuitive. Deeply sensitive and spiritual, " + "with a rich inner life." + ), +] + +MOON_INTERPRETATIONS = [ + # Aries + ( + "Emotional reactions are quick and intense; needs independence and action " + "to feel secure." + ), + # Taurus + ( + "Seeks emotional security through stability, routine and physical comfort; " + "very loyal once trust is established." + ), + # Gemini + ( + "Processes emotions intellectually; needs communication and variety to feel " + "at ease." + ), + # Cancer + "Deeply intuitive and empathetic; home and family are the emotional anchor.", + # Leo + "Needs recognition and warmth; generous and loyal in emotional bonds.", + # Virgo + "Analytical about feelings; finds comfort in being useful and well-organised.", + # Libra + "Needs harmony and partnership; dislikes conflict and seeks emotional balance.", + # Scorpio + "Intense and private emotional world; deeply loyal but prone to jealousy.", + # Sagittarius + ( + "Needs freedom and optimism; enthusiastic but may avoid deeper emotional " + "commitment." + ), + # Capricorn + "Reserved with emotions; finds security through achievement and structure.", + # Aquarius + "Detached but humanitarian; connects best through shared ideas and ideals.", + # Pisces + ("Extremely sensitive and empathetic; absorbs surrounding emotions like a sponge."), +] + +ASCENDANT_INTERPRETATIONS = [ + # Aries + ( + "Projects energy, confidence and directness. First impression is dynamic " + "and assertive." + ), + # Taurus + ( + "Projects calm, reliability and groundedness. Others see you as steady " + "and trustworthy." + ), + # Gemini + ( + "Projects curiosity and adaptability. Others see you as lively, " + "communicative and witty." + ), + # Cancer + ( + "Projects sensitivity and nurturing. Others see you as caring, protective " + "and approachable." + ), + # Leo + ( + "Projects charisma and warmth. Others see you as confident, generous and " + "magnetic." + ), + # Virgo + ( + "Projects competence and modesty. Others see you as careful, helpful " + "and analytical." + ), + # Libra + ( + "Projects elegance and sociability. Others see you as charming, fair and " + "refined." + ), + # Scorpio + ( + "Projects intensity and mystery. Others see you as powerful, perceptive " + "and magnetic." + ), + # Sagittarius + ( + "Projects enthusiasm and openness. Others see you as adventurous, honest " + "and optimistic." + ), + # Capricorn + ( + "Projects authority and seriousness. Others see you as reliable, ambitious " + "and disciplined." + ), + # Aquarius + ( + "Projects originality and independence. Others see you as unique, friendly " + "and intellectual." + ), + # Pisces + ( + "Projects sensitivity and dreaminess. Others see you as gentle, empathetic " + "and spiritual." + ), +] + +ELEMENT_DESCRIPTIONS = { + "Fire": ( + "Your chart is dominated by " + "Fire (♈♌♐). You tend " + "to be enthusiastic, action-oriented and inspiring, with a strong creative " + "drive and a natural optimism." + ), + "Earth": ( + "Your chart is dominated by " + "Earth (♉♍♑). You tend " + "to be practical, grounded and reliable, with a talent for building lasting " + "results in the material world." + ), + "Air": ( + "Your chart is dominated by " + "Air (♊♎♒). You tend to " + "be intellectual, communicative and socially oriented, with a gift for " + "connecting ideas and people." + ), + "Water": ( + "Your chart is dominated by " + "Water (♋♏♓). You tend " + "to be intuitive, empathetic and emotionally perceptive, with a deep inner " + "life and strong instincts." + ), +} + +POLARITY_DESCRIPTIONS = { + "Positive": ( + "Your chart leans towards Positive (Yang) signs " + "(♈♊♌♎♐♒). You tend to be outwardly expressive, action-oriented " + "and socially engaged." + ), + "Negative": ( + "Your chart leans towards Negative (Yin) signs " + "(♉♋♍♏♑♓). You tend to be receptive, reflective and focused on " + "inner depth and inner resources." + ), +} + +QUADRANT_DESCRIPTIONS = { + "Q1": ( + "Many planets in the First Quadrant (Houses 1–3) " + "suggest a strong focus on personal identity, self-expression and " + "immediate environment." + ), + "Q2": ( + "Many planets in the Second Quadrant (Houses 4–6) " + "suggest an emphasis on home, roots, daily routines and personal " + "resources." + ), + "Q3": ( + "Many planets in the Third Quadrant (Houses 7–9) " + "suggest a strong orientation towards relationships, partnerships " + "and the pursuit of meaning." + ), + "Q4": ( + "Many planets in the Fourth Quadrant (Houses 10–12) " + "suggest an emphasis on career, public life and collective or " + "spiritual concerns." + ), +} + +MODALITY_DESCRIPTIONS = { + "Cardinal": ( + "A strong Cardinal emphasis (♈♋♎♑) suggests you " + "are a natural initiator who starts things, takes charge and drives change." + ), + "Fixed": ( + "A strong Fixed emphasis (♉♌♏♒) suggests you are " + "persistent and determined, with the stamina to see things through and " + "resist unnecessary change." + ), + "Mutable": ( + "A strong Mutable emphasis (♊♍♐♓) suggests you " + "are adaptable and flexible, thriving in changing environments and " + "bridging transitions with ease." + ), +} + + +_ELEMENT_COLORS = { + "Fire": "#e8554e", + "Earth": "#7db87d", + "Air": "#7ba7c7", + "Water": "#9b7fc0", +} + + +def _dominant(counts): + """Return the key with the highest count, or None if all equal.""" + if not counts: + return None + max_val = max(counts.values()) + if list(counts.values()).count(max_val) > 1: + return None + return max(counts, key=counts.get) + + +def build_interpretation(env, chart_data): + """Return an HTML string interpreting the birth chart.""" + planets = chart_data["planets"] + asc = chart_data.get("ascendant") + + from .astro_calc import lon_to_sign + + sun_i = lon_to_sign(planets["sun"])[0] + moon_i = lon_to_sign(planets["moon"])[0] + + sections = [] + + # ── Sun ────────────────────────────────────────────────────────────────── + sections.append( + f"
☉ {env._('Sun')} in {SIGN_SYMBOLS[sun_i]} {env._(SIGNS[sun_i])}
" + f"

{env._(SUN_INTERPRETATIONS[sun_i])}

" + ) + + # ── Moon ───────────────────────────────────────────────────────────────── + sections.append( + f"
☽ {env._('Moon')} in {SIGN_SYMBOLS[moon_i]} {env._(SIGNS[moon_i])}
" + f"

{env._(MOON_INTERPRETATIONS[moon_i])}

" + ) + + # ── Ascendant ───────────────────────────────────────────────────────────── + if asc is not None: + asc_i = lon_to_sign(asc)[0] + asc_sign = f"{SIGN_SYMBOLS[asc_i]} {env._(SIGNS[asc_i])}" + sections.append( + f"
AC {env._('Ascendant')} in {asc_sign}
" + f"

{env._(ASCENDANT_INTERPRETATIONS[asc_i])}

" + ) + + # ── Element, modality, polarity & quadrant distribution ────────────────── + element_counts = {"Fire": 0, "Earth": 0, "Air": 0, "Water": 0} + modality_counts = {"Cardinal": 0, "Fixed": 0, "Mutable": 0} + polarity_counts = {"Positive": 0, "Negative": 0} + for key in PLANET_KEYS: + sign_i = lon_to_sign(planets[key])[0] + element_counts[SIGN_ELEMENTS[sign_i]] += 1 + modality_counts[SIGN_MODALITIES[sign_i]] += 1 + polarity_counts[SIGN_POLARITIES[sign_i]] += 1 + + dominant_el = _dominant(element_counts) + dominant_mod = _dominant(modality_counts) + dominant_pol = _dominant(polarity_counts) + + dist_rows = "".join( + f"" + f"{env._(el)}" + f"" + f"{'◉ ' * element_counts[el]}" + for el in ("Fire", "Earth", "Air", "Water") + ) + mod_rows = "".join( + f"{env._(mod)}" + f"" + f"{'◉ ' * modality_counts[mod]}" + for mod in ("Cardinal", "Fixed", "Mutable") + ) + pol_rows = "".join( + f"{env._(pol)}" + f"" + f"{'◉ ' * polarity_counts[pol]}" + for pol in ("Positive", "Negative") + ) + + def _col(title, rows_html): + return ( + f"
" + f"
{title}
" + f"{rows_html}
" + f"
" + ) + + tables_html = ( + "
" + + _col(env._("Elements"), dist_rows) + + _col(env._("Modalities"), mod_rows) + + _col(env._("Polarities"), pol_rows) + ) + + houses = chart_data.get("houses") + dominant_quad = None + if houses: + quadrant_counts = {"Q1": 0, "Q2": 0, "Q3": 0, "Q4": 0} + for key in PLANET_KEYS: + h = get_house(planets[key], houses) + if h: + quadrant_counts[f"Q{(h - 1) // 3 + 1}"] += 1 + dominant_quad = _dominant(quadrant_counts) + quad_labels = { + "Q1": env._("Q1 (1–3)"), + "Q2": env._("Q2 (4–6)"), + "Q3": env._("Q3 (7–9)"), + "Q4": env._("Q4 (10–12)"), + } + quad_rows = "".join( + f"{quad_labels[q]}" + f"" + f"{'◉ ' * quadrant_counts[q]}" + for q in ("Q1", "Q2", "Q3", "Q4") + ) + tables_html += _col(env._("Quadrants"), quad_rows) + + tables_html += "
" + + dominant_texts = [] + if dominant_el: + dominant_texts.append(f"

{env._(ELEMENT_DESCRIPTIONS[dominant_el])}

") + if dominant_mod: + dominant_texts.append(f"

{env._(MODALITY_DESCRIPTIONS[dominant_mod])}

") + if dominant_pol: + dominant_texts.append(f"

{env._(POLARITY_DESCRIPTIONS[dominant_pol])}

") + if dominant_quad: + dominant_texts.append(f"

{env._(QUADRANT_DESCRIPTIONS[dominant_quad])}

") + + sections.append( + f"
{env._('Chart Balance')}
" + + tables_html + + ("
" + "".join(dominant_texts) if dominant_texts else "") + ) + + return "
" + "".join(sections) + "
" + + +# Keys: (transiting_planet_key, aspect_name) +# {natal} is replaced at render time with the natal planet name. +TRANSIT_ASPECT_INTERPRETATIONS = { + # ── Sun ────────────────────────────────────────────────────────────────── + ("sun", "Conjunction"): ( + "Solar energy illuminates and energises your natal {natal}. " + "Awareness, vitality and intention peak in {natal}'s themes. " + "This is a good moment to act consciously and decisively." + ), + ("sun", "Trine"): ( + "Confidence and creative clarity flow harmoniously through your natal " + "{natal}. Self-expression and initiative are well-supported right now." + ), + ("sun", "Sextile"): ( + "Positive opportunities for self-expression arise around your natal " + "{natal}. Vitality and initiative can be directed productively today." + ), + ("sun", "Square"): ( + "Creative tension between your will and natal {natal} calls for " + "conscious effort. Channel any friction into growth rather than conflict." + ), + ("sun", "Opposition"): ( + "External circumstances or other people mirror your natal {natal} back " + "to you. Objectivity and balance help you integrate the tension well." + ), + # ── Moon ───────────────────────────────────────────────────────────────── + ("moon", "Conjunction"): ( + "Emotional sensitivity peaks around your natal {natal}. Instincts and " + "feelings are heightened, so trust your gut in {natal}'s domain today." + ), + ("moon", "Trine"): ( + "Emotional harmony flows through your natal {natal}. Intuition and " + "domestic life support {natal}'s natural expression right now." + ), + ("moon", "Sextile"): ( + "Gentle emotional support nurtures your natal {natal}. " + "Relationships and daily rhythms feel easy and comfortable." + ), + ("moon", "Square"): ( + "Emotional tension or restlessness challenges your natal {natal}. " + "Mood fluctuations are normal; patience and self-care help." + ), + ("moon", "Opposition"): ( + "Emotional needs come into tension with your natal {natal}'s themes. " + "Nurture yourself while staying open to others' perspectives." + ), + # ── Mercury ─────────────────────────────────────────────────────────────── + ("mercury", "Conjunction"): ( + "Mercury sharpens thinking and communication around your natal {natal}. " + "Ideas flow quickly; important conversations or decisions are highlighted." + ), + ("mercury", "Trine"): ( + "Clear, harmonious thinking supports your natal {natal}. " + "A good time for writing, negotiating or learning in {natal}'s areas." + ), + ("mercury", "Sextile"): ( + "Mental opportunities arise around your natal {natal}. " + "Communications and short-term plans tend to go smoothly today." + ), + ("mercury", "Square"): ( + "Mental tension or miscommunication may surface around your natal " + "{natal}. Double-check details and aim for clarity in all exchanges." + ), + ("mercury", "Opposition"): ( + "Others' ideas challenge or stimulate your natal {natal}. " + "Listen as much as you speak; different perspectives are valuable." + ), + # ── Venus ───────────────────────────────────────────────────────────────── + ("venus", "Conjunction"): ( + "Venus brings charm, harmony and pleasure to your natal {natal}. " + "Social ease and aesthetic appreciation are highlighted today." + ), + ("venus", "Trine"): ( + "Harmony and enjoyment flow easily around your natal {natal}. " + "Relationships, beauty and creative pursuits are favoured." + ), + ("venus", "Sextile"): ( + "Gentle social and creative opportunities arise around your natal " + "{natal}. Diplomacy and warmth come naturally right now." + ), + ("venus", "Square"): ( + "Indulgence or disharmony may challenge your natal {natal}. " + "Balance pleasure with responsibility and avoid overcommitting." + ), + ("venus", "Opposition"): ( + "Relationship tensions or differing values involve your natal {natal}. " + "Seek balance between your own needs and those of others." + ), + # ── Mars ────────────────────────────────────────────────────────────────── + ("mars", "Conjunction"): ( + "Mars ignites your natal {natal} with drive and assertiveness. " + "Energy and initiative peak, so act decisively but avoid impulsiveness." + ), + ("mars", "Trine"): ( + "Energetic support flows easily to your natal {natal}. " + "Physical vitality and motivation make this a good time for bold action." + ), + ("mars", "Sextile"): ( + "A helpful burst of energy supports your natal {natal}. " + "Courage and initiative are available for productive, targeted effort." + ), + ("mars", "Square"): ( + "Friction and impatience challenge your natal {natal}. " + "Avoid rash decisions; channel this intensity into constructive effort." + ), + ("mars", "Opposition"): ( + "Competing drives or others' assertiveness challenge your natal {natal}. " + "Seek compromise and direct your energy wisely to avoid confrontation." + ), + # ── Jupiter ─────────────────────────────────────────────────────────────── + ("jupiter", "Conjunction"): ( + "A powerful surge of Jupiterian energy activates your natal {natal}. " + "Opportunities for growth and abundance arise in {natal}'s themes." + ), + ("jupiter", "Trine"): ( + "Fortunate energy flows easily to your natal {natal}. " + "A period of natural growth, positive developments and deserved rewards." + ), + ("jupiter", "Sextile"): ( + "Meaningful opportunities related to your natal {natal} come your way. " + "Effort invested in {natal}'s themes now is likely to pay off well." + ), + ("jupiter", "Square"): ( + "Tension between growth and limits challenges your natal {natal}. " + "Overconfidence may need tempering; use this energy to overcome obstacles." + ), + ("jupiter", "Opposition"): ( + "A need to balance expansion with reality around your natal {natal}. " + "Others may bring opportunity or inflated expectations, so stay discerning." + ), + # ── Saturn ──────────────────────────────────────────────────────────────── + ("saturn", "Conjunction"): ( + "Saturn's serious energy meets your natal {natal}. " + "A period of testing and consolidation: structures built now are built to last." + ), + ("saturn", "Trine"): ( + "Saturn supports your natal {natal} with stability and practical wisdom. " + "A good time to formalise commitments or take disciplined, lasting action." + ), + ("saturn", "Sextile"): ( + "Practical opportunities to strengthen your natal {natal}'s expression " + "arise. Steady, responsible effort leads to tangible, lasting results." + ), + ("saturn", "Square"): ( + "Saturn tests your natal {natal}, exposing weaknesses to be addressed. " + "Challenges, met with patience, build resilience and long-term strength." + ), + ("saturn", "Opposition"): ( + "External pressures or authority figures challenge your natal {natal}. " + "Honest assessment and taking responsibility bring clarity and growth." + ), + # ── Uranus ──────────────────────────────────────────────────────────────── + ("uranus", "Conjunction"): ( + "Sudden, liberating change disrupts your natal {natal}. " + "Unexpected breakthroughs or upheavals shake up established patterns." + ), + ("uranus", "Trine"): ( + "Exciting innovations and positive changes relate to your natal {natal}. " + "Freedom, originality and new approaches are easily expressed now." + ), + ("uranus", "Sextile"): ( + "Refreshing new ideas create positive shifts around your natal {natal}. " + "Openness to change and flexibility bring real benefits at this time." + ), + ("uranus", "Square"): ( + "Disruption and restlessness challenge your natal {natal}. " + "The urge for radical change creates tension; avoid impulsive decisions." + ), + ("uranus", "Opposition"): ( + "Unexpected events or people disrupt your natal {natal}. " + "Adaptability and flexibility are essential to navigate these changes." + ), + # ── Neptune ─────────────────────────────────────────────────────────────── + ("neptune", "Conjunction"): ( + "Neptune dissolves or spiritualises your natal {natal}. " + "Intuition heightens, but confusion or idealisation is also possible." + ), + ("neptune", "Trine"): ( + "Spiritual sensitivity and creative inspiration flow gently through " + "your natal {natal}. Intuition and empathy are naturally heightened." + ), + ("neptune", "Sextile"): ( + "Subtle spiritual or creative opportunities emerge around your natal " + "{natal}. Dreams and intuition offer useful, gentle guidance now." + ), + ("neptune", "Square"): ( + "Confusion or idealisation may cloud your natal {natal}'s expression. " + "Be wary of self-deception and keep yourself grounded in clear facts." + ), + ("neptune", "Opposition"): ( + "Illusions or escapism challenge your natal {natal}. " + "Grounding and discernment are essential; reality may need reassessing." + ), + # ── Pluto ───────────────────────────────────────────────────────────────── + ("pluto", "Conjunction"): ( + "Profound, irreversible transformation touches your natal {natal}. " + "Old patterns are stripped away to reveal deeper power and authentic truth." + ), + ("pluto", "Trine"): ( + "Deep, empowering change flows smoothly through your natal {natal}. " + "Transformation feels purposeful and ultimately regenerative." + ), + ("pluto", "Sextile"): ( + "An opportunity for deep personal growth around your natal {natal} " + "arises. Subtle but meaningful transformation is available if embraced." + ), + ("pluto", "Square"): ( + "Intense power struggles or compulsive forces challenge your natal " + "{natal}. Resistance amplifies pressure; embrace the necessary change." + ), + ("pluto", "Opposition"): ( + "External forces of change confront your natal {natal}. " + "Old structures may collapse; rebirth comes through letting go." + ), +} + + +def build_transit_interpretation(env, natal_data, transit_data, aspects, today): + """Return (aspects_html, interpretations_html) for the transit chart. + + aspects_html — date header + active aspects table (goes in the side column). + interpretations_html — per-aspect interpretation texts (goes full-width below). + """ + from .astro_calc import ( + PLANET_KEYS, + PLANET_NAMES, + PLANET_SYMBOLS, + SIGN_SYMBOLS, + SIGNS, + lon_to_sign, + ) + + planets_t = transit_data["planets"] + + date_str = today.strftime("%B %d, %Y") + date_header = ( + f"

" + f"{env._('Transits for')} {date_str} " + f"{env._('compared to natal chart')}

" + ) + + tight = sorted([a for a in aspects if a["orb"] <= 8], key=lambda a: a["orb"])[:12] + + if not tight: + no_aspects = f"

{env._('No major aspects currently active.')}

" + return ( + "
" + + date_header + + no_aspects + + "
", + "", + ) + + rows_html = "" + interp_html = "" + for asp in tight: + ti = PLANET_KEYS.index(asp["transit_key"]) + ni = PLANET_KEYS.index(asp["natal_key"]) + t_sym = PLANET_SYMBOLS[ti] + n_sym = PLANET_SYMBOLS[ni] + t_name = env._(PLANET_NAMES[ti]) + n_name = env._(PLANET_NAMES[ni]) + t_sign_i = lon_to_sign(planets_t[asp["transit_key"]])[0] + rows_html += ( + f"" + f"" + f"{t_sym}" + f"" + f"{SIGN_SYMBOLS[t_sign_i]} {env._(SIGNS[t_sign_i])}" + f"" + f"{asp['symbol']}" + f"" + f"{env._(asp['aspect'])}" + f"" + f"{n_sym}" + f"" + f"{asp['orb']}°" + f"" + ) + key = (asp["transit_key"], asp["aspect"]) + text = TRANSIT_ASPECT_INTERPRETATIONS.get(key, "") + if text and asp["orb"] <= 6: + filled = env._(text).format(natal=n_name) + aspect_label = env._(asp["aspect"]).lower() + aspect_colored = f"{aspect_label}" + heading = env._( + "Transiting %(transit)s forms a %(aspect)s with your natal %(natal)s", + transit=t_name, + aspect=aspect_colored, + natal=n_name, + ) + interp_html += ( + f"
" + f"" + f"{t_sym} {asp['symbol']} {n_sym}" + f" {heading}" + f"{asp['orb']}°" + f"
" + f"

{filled}

" + ) + + aspects_table = ( + f"
{env._('Active Aspects (Transit → Natal)')}
" + "" + "" + f"" + f"" + f"" + f"" + f"" + f"{rows_html}
{env._('Transit')}{env._('In Sign')}{env._('Aspect')}{env._('Natal')}{env._('Orb')}
" + ) + aspects_html = ( + "
" + + date_header + + aspects_table + + "
" + ) + interp_out = ( + ( + "
" + f"
{env._('Transit Interpretations')}
" + + interp_html + + "
" + ) + if interp_html + else "" + ) + return aspects_html, interp_out diff --git a/hr_birth_astral_chart/pyproject.toml b/hr_birth_astral_chart/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/hr_birth_astral_chart/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/hr_birth_astral_chart/readme/CONTRIBUTORS.md b/hr_birth_astral_chart/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..1da3d73e294 --- /dev/null +++ b/hr_birth_astral_chart/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Miquel Raïch \<\> diff --git a/hr_birth_astral_chart/readme/DESCRIPTION.md b/hr_birth_astral_chart/readme/DESCRIPTION.md new file mode 100644 index 00000000000..8bdd7f9ff99 --- /dev/null +++ b/hr_birth_astral_chart/readme/DESCRIPTION.md @@ -0,0 +1,16 @@ +View your full Western astrological birth chart directly from your employee profile. + +Depends on `hr_birth_data` for the birth time and coordinates fields. + +Adds an **Astral Chart** tab to the employee form showing: + +- **Sun Sign**, **Moon Sign** and **Ascendant** summary badges +- An SVG zodiac wheel with planetary positions +- A detailed table with degree, minute, sign and house for each planet + (Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn, Uranus, Neptune, Pluto, + Chiron, Black Moon Lilith, Ceres) +- Astrological houses (Whole Sign system) when birth time and location are provided +- Current transits biwheel and interpretation + +Astronomical calculations use `pyswisseph` (Python binding for Swiss Ephemeris) +for high-precision planetary positions. diff --git a/hr_birth_astral_chart/static/description/index.html b/hr_birth_astral_chart/static/description/index.html new file mode 100644 index 00000000000..cf8333b83aa --- /dev/null +++ b/hr_birth_astral_chart/static/description/index.html @@ -0,0 +1,441 @@ + + + + + +README.rst + + + +
+ + +Odoo Community Association +
+

HR Birth Astral Chart

+ +

Beta License: AGPL-3 OCA/hr Translate me on Weblate Try me on Runboat

+

View your full Western astrological birth chart directly from your +employee profile.

+

Depends on hr_birth_data for the birth time and coordinates fields.

+

Adds an Astral Chart tab to the employee form showing:

+
    +
  • Sun Sign, Moon Sign and Ascendant summary badges
  • +
  • An SVG zodiac wheel with planetary positions
  • +
  • A detailed table with degree, minute, sign and house for each planet +(Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn, Uranus, Neptune, +Pluto, Chiron, Black Moon Lilith, Ceres)
  • +
  • Astrological houses (Whole Sign system) when birth time and location +are provided
  • +
  • Current transits biwheel and interpretation
  • +
+

Astronomical calculations use pyswisseph (Python binding for Swiss +Ephemeris) for high-precision planetary positions.

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • ForgeFlow
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

MiquelRForgeFlow

+

This module is part of the OCA/hr project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/hr_birth_astral_chart/static/src/js/birth_chart_table.esm.js b/hr_birth_astral_chart/static/src/js/birth_chart_table.esm.js new file mode 100644 index 00000000000..93c7860ffd4 --- /dev/null +++ b/hr_birth_astral_chart/static/src/js/birth_chart_table.esm.js @@ -0,0 +1,44 @@ +// @odoo-module +import {Component} from "@odoo/owl"; +import {registry} from "@web/core/registry"; +import {standardFieldProps} from "@web/views/fields/standard_field_props"; + +const SIGN_COLORS = [ + "#e8554e", + "#7db87d", + "#7ba7c7", + "#9b7fc0", + "#e8554e", + "#7db87d", + "#7ba7c7", + "#9b7fc0", + "#e8554e", + "#7db87d", + "#7ba7c7", + "#9b7fc0", +]; + +class BirthChartTableWidget extends Component { + static template = "hr_birth_astral_chart.BirthChartTable"; + static props = {...standardFieldProps}; + + get rows() { + try { + const value = this.props.record.data[this.props.name]; + return JSON.parse(value || "[]"); + } catch { + return []; + } + } + + signColor(signIndex) { + return SIGN_COLORS[signIndex] || "#888"; + } +} + +registry.category("fields").add("birth_chart_table", { + component: BirthChartTableWidget, + supportedTypes: ["char"], +}); + +export {BirthChartTableWidget}; diff --git a/hr_birth_astral_chart/static/src/xml/birth_chart_table.xml b/hr_birth_astral_chart/static/src/xml/birth_chart_table.xml new file mode 100644 index 00000000000..c4c9c1bc13a --- /dev/null +++ b/hr_birth_astral_chart/static/src/xml/birth_chart_table.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + +
PlanetSignPositionHouse
+ + + + + + + + + + + + + + + + + House + +
+
+ +
diff --git a/hr_birth_astral_chart/views/hr_employee_views.xml b/hr_birth_astral_chart/views/hr_employee_views.xml new file mode 100644 index 00000000000..6bbe2885580 --- /dev/null +++ b/hr_birth_astral_chart/views/hr_employee_views.xml @@ -0,0 +1,167 @@ + + + + + hr.employee.birth_astral_chart.form + hr.employee + + + + + + + +
+ +
+
+
Sun Sign
+
+ +
+
+
+
Moon Sign
+
+ +
+
+
+
Ascendant
+
+ +
+
+
+ + Add birth time and coordinates to compute the + Ascendant and Houses +
+
+ + +
+
+ +
+
+
Planetary Positions
+ +
+
Houses
+ +
+
+
+ + +
+
+
+ Chart Interpretation +
+ +
+
+ + +
+
+
+ Current Transits: + how today's sky influences your natal chart +
+
+
+ +
+
+
+ Transit Positions +
+ + +
+
+
+ +
+
+
+
+
+
+
+
+
diff --git a/hr_birth_data/README.rst b/hr_birth_data/README.rst new file mode 100644 index 00000000000..2f1930fdd0b --- /dev/null +++ b/hr_birth_data/README.rst @@ -0,0 +1,101 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +============= +HR Birth Data +============= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:48ad5838d6537909e62b5a3a267b7d233979544d648b8d6e5b30ebd307288954 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fhr-lightgray.png?logo=github + :target: https://github.com/OCA/hr/tree/19.0/hr_birth_data + :alt: OCA/hr +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/hr-19-0/hr-19-0-hr_birth_data + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/hr&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Adds birth time and birth coordinates to the employee profile. + +Provides the base fields used by astrological and other birth-data +modules: + +- **Birth Time** (``birth_hour``): decimal hours, e.g. 14.5 = 14:30 +- **Birth Latitude / Longitude**: geographic coordinates of the birth + place + +These fields are displayed in the *Private Information* tab of the +employee form, alongside the existing *Place of Birth* field. + +This module contains no calculations — it is a data layer intended to be +extended by modules such as ``hr_birth_astral_chart``. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* ForgeFlow + +Contributors +------------ + +- Miquel Raïch + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-MiquelRForgeFlow| image:: https://github.com/MiquelRForgeFlow.png?size=40px + :target: https://github.com/MiquelRForgeFlow + :alt: MiquelRForgeFlow + +Current `maintainer `__: + +|maintainer-MiquelRForgeFlow| + +This module is part of the `OCA/hr `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/hr_birth_data/__init__.py b/hr_birth_data/__init__.py new file mode 100644 index 00000000000..34d939457c3 --- /dev/null +++ b/hr_birth_data/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Forgeflow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import models diff --git a/hr_birth_data/__manifest__.py b/hr_birth_data/__manifest__.py new file mode 100644 index 00000000000..01546958362 --- /dev/null +++ b/hr_birth_data/__manifest__.py @@ -0,0 +1,19 @@ +# Copyright 2026 Forgeflow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +{ + "name": "HR Birth Data", + "version": "19.0.1.0.0", + "category": "Human Resources", + "website": "https://github.com/OCA/hr", + "author": "ForgeFlow, Odoo Community Association (OCA)", + "maintainers": ["MiquelRForgeFlow"], + "license": "AGPL-3", + "installable": True, + "application": False, + "summary": "Adds birth time and birth coordinates to the employee profile", + "depends": ["hr"], + "data": [ + "views/hr_employee_views.xml", + ], +} diff --git a/hr_birth_data/models/__init__.py b/hr_birth_data/models/__init__.py new file mode 100644 index 00000000000..081bfa341a5 --- /dev/null +++ b/hr_birth_data/models/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Forgeflow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import hr_employee diff --git a/hr_birth_data/models/hr_employee.py b/hr_birth_data/models/hr_employee.py new file mode 100644 index 00000000000..879a10ae66b --- /dev/null +++ b/hr_birth_data/models/hr_employee.py @@ -0,0 +1,30 @@ +# Copyright 2026 Forgeflow S.L. +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class HrEmployee(models.Model): + _inherit = "hr.employee" + + birth_hour = fields.Float( + string="Birth Time", + help="Birth time in decimal hours (e.g. 14.5 = 14:30). " + "Required to compute the Ascendant and Houses.", + ) + birth_latitude = fields.Float( + digits=(10, 6), + help="Geographic latitude of birth place (+ north, − south).", + ) + birth_longitude = fields.Float( + digits=(10, 6), + help="Geographic longitude of birth place (+ east, − west).", + ) + birth_location_known = fields.Boolean( + compute="_compute_birth_location_known", + ) + + @api.depends("birth_latitude", "birth_longitude") + def _compute_birth_location_known(self): + for rec in self: + rec.birth_location_known = bool(rec.birth_latitude or rec.birth_longitude) diff --git a/hr_birth_data/pyproject.toml b/hr_birth_data/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/hr_birth_data/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/hr_birth_data/readme/CONTRIBUTORS.md b/hr_birth_data/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..1da3d73e294 --- /dev/null +++ b/hr_birth_data/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Miquel Raïch \<\> diff --git a/hr_birth_data/readme/DESCRIPTION.md b/hr_birth_data/readme/DESCRIPTION.md new file mode 100644 index 00000000000..704c2383a51 --- /dev/null +++ b/hr_birth_data/readme/DESCRIPTION.md @@ -0,0 +1,14 @@ +Adds birth time and birth coordinates to the employee profile. + +Provides the base fields used by astrological and other birth-data +modules: + +- **Birth Time** (``birth_hour``): decimal hours, e.g. 14.5 = 14:30 +- **Birth Latitude / Longitude**: geographic coordinates of the birth + place + +These fields are displayed in the *Private Information* tab of the +employee form, alongside the existing *Place of Birth* field. + +This module contains no calculations — it is a data layer intended to be +extended by modules such as ``hr_birth_astral_chart``. diff --git a/hr_birth_data/static/description/index.html b/hr_birth_data/static/description/index.html new file mode 100644 index 00000000000..eb4fd76e22c --- /dev/null +++ b/hr_birth_data/static/description/index.html @@ -0,0 +1,437 @@ + + + + + +README.rst + + + +
+ + +Odoo Community Association +
+

HR Birth Data

+ +

Beta License: AGPL-3 OCA/hr Translate me on Weblate Try me on Runboat

+

Adds birth time and birth coordinates to the employee profile.

+

Provides the base fields used by astrological and other birth-data +modules:

+
    +
  • Birth Time (birth_hour): decimal hours, e.g. 14.5 = 14:30
  • +
  • Birth Latitude / Longitude: geographic coordinates of the birth +place
  • +
+

These fields are displayed in the Private Information tab of the +employee form, alongside the existing Place of Birth field.

+

This module contains no calculations — it is a data layer intended to be +extended by modules such as hr_birth_astral_chart.

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • ForgeFlow
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

MiquelRForgeFlow

+

This module is part of the OCA/hr project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+
+ + diff --git a/hr_birth_data/views/hr_employee_views.xml b/hr_birth_data/views/hr_employee_views.xml new file mode 100644 index 00000000000..e42fb506bab --- /dev/null +++ b/hr_birth_data/views/hr_employee_views.xml @@ -0,0 +1,37 @@ + + + + + hr.employee.birth_data.form + hr.employee + + + + + + + diff --git a/requirements.txt b/requirements.txt index 7d41f1be0f9..db36908f9c1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ # generated from manifests external_dependencies +pyswisseph python-dateutil