Repository: bo4e/BO4E-python (language: python)

Issue:
Implement COM Angebotsvariante
https://www.bo4e.de/dokumentation/komponenten/com-angebotsvariante
- #82 
- #144 
- #168 
- #229 


Discussion / hints:

Fix patch:
diff --git a/src/bo4e/com/angebotsvariante.py b/src/bo4e/com/angebotsvariante.py
new file mode 100644
index 000000000..9331a59a8
--- /dev/null
+++ b/src/bo4e/com/angebotsvariante.py
@@ -0,0 +1,77 @@
+"""
+Contains Angebotsvariante and corresponding marshmallow schema for de-/serialization
+"""
+from datetime import datetime
+from typing import List, Optional
+
+import attr
+from marshmallow import fields
+from marshmallow_enum import EnumField  # type:ignore[import]
+
+from bo4e.com.angebotsteil import Angebotsteil, AngebotsteilSchema
+from bo4e.com.betrag import Betrag, BetragSchema
+from bo4e.com.com import COM, COMSchema
+from bo4e.com.menge import Menge, MengeSchema
+from bo4e.enum.angebotsstatus import Angebotsstatus
+from bo4e.validators import check_list_length_at_least_one
+
+
+# pylint: disable=too-few-public-methods
+@attr.s(auto_attribs=True, kw_only=True)
+class Angebotsvariante(COM):
+    """
+    Führt die verschiedenen Ausprägungen der Angebotsberechnung auf
+    """
+
+    # required attributes
+    #: Gibt den Status eines Angebotes an.
+    angebotsstatus: Angebotsstatus = attr.ib(validator=attr.validators.instance_of(Angebotsstatus))
+
+    #: Datum der Erstellung der Angebotsvariante
+    erstellungsdatum: datetime = attr.ib(validator=attr.validators.instance_of(datetime))
+
+    #: Bis zu diesem Zeitpunkt gilt die Angebotsvariante
+    bindefrist: datetime = attr.ib(validator=attr.validators.instance_of(datetime))
+
+    teile: List[Angebotsteil] = attr.ib(
+        validator=[
+            attr.validators.deep_iterable(
+                member_validator=attr.validators.instance_of(Angebotsteil),
+                iterable_validator=attr.validators.instance_of(list),
+            ),
+            check_list_length_at_least_one,
+        ]
+    )
+    """
+    Angebotsteile werden im einfachsten Fall für eine Marktlokation oder Lieferstellenadresse erzeugt.
+    Hier werden die Mengen und Gesamtkosten aller Angebotspositionen zusammengefasst.
+    Eine Variante besteht mindestens aus einem Angebotsteil.
+    """
+
+    # optional attributes
+    #: Aufsummierte Wirkarbeitsmenge aller Angebotsteile
+    gesamtmenge: Optional[Menge] = attr.ib(
+        default=None, validator=attr.validators.optional(attr.validators.instance_of(Menge))
+    )
+    # todo: write a validator for this: https://github.com/Hochfrequenz/BO4E-python/issues/320
+    #: Aufsummierte Kosten aller Angebotsteile
+    gesamtkosten: Optional[Betrag] = attr.ib(
+        default=None, validator=attr.validators.optional(attr.validators.instance_of(Betrag))
+    )
+
+
+class AngebotsvarianteSchema(COMSchema):
+    """
+    Schema for de-/serialization of Angebotsvariante
+    """
+
+    class_name = Angebotsvariante
+    # required attributes
+    angebotsstatus = EnumField(Angebotsstatus)
+    erstellungsdatum = fields.DateTime()
+    bindefrist = fields.DateTime()
+    teile = fields.List(fields.Nested(AngebotsteilSchema))
+
+    # optional attributes
+    gesamtmenge = fields.Nested(MengeSchema, allow_none=True)
+    gesamtkosten = fields.Nested(BetragSchema, allow_none=True)


Test patch:
diff --git a/tests/test_angebotsteil.py b/tests/test_angebotsteil.py
index e9ca4d129..bc002384c 100644
--- a/tests/test_angebotsteil.py
+++ b/tests/test_angebotsteil.py
@@ -20,6 +20,41 @@
 from bo4e.enum.waehrungseinheit import Waehrungseinheit
 from tests.serialization_helper import assert_serialization_roundtrip  # type:ignore[import]
 
+example_angebotsteil: Angebotsteil = Angebotsteil(
+    positionen=[
+        Angebotsposition(
+            positionsbezeichnung="teststring",
+            positionsmenge=Menge(wert=Decimal(4000), einheit=Mengeneinheit.KWH),
+            positionspreis=Preis(wert=Decimal(0.2456), einheit=Waehrungseinheit.EUR, bezugswert=Mengeneinheit.KWH),
+            positionskosten=Betrag(
+                waehrung=Waehrungscode.EUR,
+                wert=Decimal(98240),
+            ),
+        )
+    ],
+)
+
+example_angebotsteil_json = {
+    "positionen": [
+        {
+            "positionsbezeichnung": "teststring",
+            "positionsmenge": {"wert": "4000", "einheit": "KWH"},
+            "positionskosten": {"waehrung": "EUR", "wert": "98240"},
+            "positionspreis": {
+                "bezugswert": "KWH",
+                "status": None,
+                "wert": "0.2456000000000000127453603226967970840632915496826171875",
+                "einheit": "EUR",
+            },
+        },
+    ],
+    "anfrageSubreferenz": None,
+    "lieferstellenangebotsteil": None,
+    "gesamtmengeangebotsteil": None,
+    "gesamtkostenangebotsteil": None,
+    "lieferzeitraum": None,
+}
+
 
 class TestAngebotsteil:
     @pytest.mark.parametrize(
@@ -130,41 +165,8 @@ class TestAngebotsteil:
                 id="maximal attributes",
             ),
             pytest.param(
-                Angebotsteil(
-                    positionen=[
-                        Angebotsposition(
-                            positionsbezeichnung="teststring",
-                            positionsmenge=Menge(wert=Decimal(4000), einheit=Mengeneinheit.KWH),
-                            positionspreis=Preis(
-                                wert=Decimal(0.2456), einheit=Waehrungseinheit.EUR, bezugswert=Mengeneinheit.KWH
-                            ),
-                            positionskosten=Betrag(
-                                waehrung=Waehrungscode.EUR,
-                                wert=Decimal(98240),
-                            ),
-                        )
-                    ],
-                ),
-                {
-                    "positionen": [
-                        {
-                            "positionsbezeichnung": "teststring",
-                            "positionsmenge": {"wert": "4000", "einheit": "KWH"},
-                            "positionskosten": {"waehrung": "EUR", "wert": "98240"},
-                            "positionspreis": {
-                                "bezugswert": "KWH",
-                                "status": None,
-                                "wert": "0.2456000000000000127453603226967970840632915496826171875",
-                                "einheit": "EUR",
-                            },
-                        },
-                    ],
-                    "anfrageSubreferenz": None,
-                    "lieferstellenangebotsteil": None,
-                    "gesamtmengeangebotsteil": None,
-                    "gesamtkostenangebotsteil": None,
-                    "lieferzeitraum": None,
-                },
+                example_angebotsteil,
+                example_angebotsteil_json,
                 id="minimal attributes",
             ),
         ],
diff --git a/tests/test_angebotsvariante.py b/tests/test_angebotsvariante.py
new file mode 100644
index 000000000..62bcf5c13
--- /dev/null
+++ b/tests/test_angebotsvariante.py
@@ -0,0 +1,67 @@
+import datetime
+
+import pytest  # type:ignore[import]
+
+from bo4e.com.angebotsvariante import Angebotsvariante, AngebotsvarianteSchema
+from bo4e.enum.angebotsstatus import Angebotsstatus
+from tests.serialization_helper import assert_serialization_roundtrip  # type:ignore[import]
+from tests.test_angebotsteil import example_angebotsteil, example_angebotsteil_json  # type:ignore[import]
+from tests.test_betrag import example_betrag, example_betrag_json  # type:ignore[import]
+from tests.test_menge import example_menge  # type:ignore[import]
+
+
+class TestAngebotsvariante:
+    @pytest.mark.parametrize(
+        "angebotsvariante, expected_json_dict",
+        [
+            pytest.param(
+                Angebotsvariante(
+                    angebotsstatus=Angebotsstatus.NACHGEFASST,
+                    bindefrist=datetime.datetime(2022, 2, 1, 0, 0, 0, tzinfo=datetime.timezone.utc),
+                    erstellungsdatum=datetime.datetime(2021, 12, 22, 0, 0, 0, tzinfo=datetime.timezone.utc),
+                    teile=[example_angebotsteil],
+                ),
+                {
+                    "gesamtmenge": None,
+                    "angebotsstatus": "NACHGEFASST",
+                    "erstellungsdatum": "2021-12-22T00:00:00+00:00",
+                    "gesamtkosten": None,
+                    "bindefrist": "2022-02-01T00:00:00+00:00",
+                    "teile": [example_angebotsteil_json],
+                },
+                id="minimal attributes",
+            ),
+            pytest.param(
+                Angebotsvariante(
+                    angebotsstatus=Angebotsstatus.NACHGEFASST,
+                    bindefrist=datetime.datetime(2022, 2, 1, 0, 0, 0, tzinfo=datetime.timezone.utc),
+                    erstellungsdatum=datetime.datetime(2021, 12, 22, 0, 0, 0, tzinfo=datetime.timezone.utc),
+                    teile=[example_angebotsteil],
+                    gesamtmenge=example_menge,
+                    gesamtkosten=example_betrag,
+                ),
+                {
+                    "gesamtmenge": {"einheit": "MWH", "wert": "3.410000000000000142108547152020037174224853515625"},
+                    # this is a problem for https://github.com/Hochfrequenz/BO4E-python/issues/249
+                    # I just reused the example_menge but don't attempt to fix it in the context of the Angebotsvariante
+                    "angebotsstatus": "NACHGEFASST",
+                    "erstellungsdatum": "2021-12-22T00:00:00+00:00",
+                    "gesamtkosten": example_betrag_json,
+                    "bindefrist": "2022-02-01T00:00:00+00:00",
+                    "teile": [example_angebotsteil_json],
+                },
+                id="max attributes",  # = min + menge and betrag
+            ),
+        ],
+    )
+    def test_serialization_roundtrip(self, angebotsvariante: Angebotsvariante, expected_json_dict: dict):
+        """
+        Test de-/serialisation roundtrip.
+        """
+        assert_serialization_roundtrip(angebotsvariante, AngebotsvarianteSchema(), expected_json_dict)
+
+    def test_missing_required_attribute(self):
+        with pytest.raises(TypeError) as excinfo:
+            _ = Angebotsvariante()
+
+        assert "missing 4 required" in str(excinfo.value)
diff --git a/tests/test_betrag.py b/tests/test_betrag.py
index 0614072be..bb4144857 100644
--- a/tests/test_betrag.py
+++ b/tests/test_betrag.py
@@ -6,18 +6,19 @@
 from bo4e.enum.waehrungscode import Waehrungscode
 from tests.serialization_helper import assert_serialization_roundtrip  # type:ignore[import]
 
+example_betrag = Betrag(
+    waehrung=Waehrungscode.EUR,
+    wert=Decimal(12.5),
+)
+
+example_betrag_json = {"wert": "12.5", "waehrung": "EUR"}
+
 
 class TestBetrag:
     @pytest.mark.parametrize(
         "betrag, expected_json_dict",
         [
-            pytest.param(
-                Betrag(
-                    waehrung=Waehrungscode.EUR,
-                    wert=Decimal(12.5),
-                ),
-                {"wert": "12.5", "waehrung": "EUR"},
-            ),
+            pytest.param(example_betrag, example_betrag_json),
         ],
     )
     def test_regionskriterium_serialization_roundtrip(self, betrag: Betrag, expected_json_dict: dict):