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

Issue:
Implement COM Ausschreibungslos
https://www.bo4e.de/dokumentation/komponenten/com-ausschreibungslos
requires 
- #124 
- #102 
- #91 
- #142 



Discussion / hints:

Fix patch:
diff --git a/src/bo4e/com/ausschreibungslos.py b/src/bo4e/com/ausschreibungslos.py
new file mode 100644
index 000000000..cbd17d287
--- /dev/null
+++ b/src/bo4e/com/ausschreibungslos.py
@@ -0,0 +1,121 @@
+"""
+Contains Ausschreibungslos class and corresponding marshmallow schema for de-/serialization
+"""
+
+from typing import List, Optional
+
+import attr
+from marshmallow import fields
+from marshmallow_enum import EnumField  # type:ignore[import]
+
+from bo4e.com.ausschreibungsdetail import Ausschreibungsdetail, AusschreibungsdetailSchema
+from bo4e.com.com import COM, COMSchema
+from bo4e.com.menge import Menge, MengeSchema
+from bo4e.com.zeitraum import Zeitraum, ZeitraumSchema
+from bo4e.enum.preismodell import Preismodell
+from bo4e.enum.rechnungslegung import Rechnungslegung
+from bo4e.enum.sparte import Sparte
+from bo4e.enum.vertragsform import Vertragsform
+from bo4e.validators import check_list_length_at_least_one
+
+
+# pylint: disable=too-few-public-methods, too-many-instance-attributes
+@attr.s(auto_attribs=True, kw_only=True)
+class Ausschreibungslos(COM):
+    """
+    Eine Komponente zur Abbildung einzelner Lose einer Ausschreibung
+    """
+
+    # required attributes
+    #: Laufende Nummer des Loses
+    losnummer: str = attr.ib(validator=attr.validators.instance_of(str))
+    #: Bezeichnung der Ausschreibung
+    bezeichnung: str = attr.ib(validator=attr.validators.instance_of(str))
+    #: Bezeichnung der Preismodelle in Ausschreibungen für die Energielieferung
+    preismodell: Preismodell = attr.ib(validator=attr.validators.instance_of(Preismodell))
+
+    #: Unterscheidungsmöglichkeiten für die Sparte
+    energieart: Sparte = attr.ib(validator=attr.validators.instance_of(Sparte))
+    #: Aufzählung der Möglichkeiten zur Rechnungslegung in Ausschreibungen
+    wunsch_rechnungslegung: Rechnungslegung = attr.ib(validator=attr.validators.instance_of(Rechnungslegung))
+    #: Aufzählung der Möglichkeiten zu Vertragsformen in Ausschreibungen
+    wunsch_vertragsform: Vertragsform = attr.ib(validator=attr.validators.instance_of(Vertragsform))
+    #: Name des Lizenzpartners
+    betreut_durch: str = attr.ib(validator=attr.validators.instance_of(str))
+    #: Anzahl der Lieferstellen in dieser Ausschreibung
+    anzahl_lieferstellen: int = attr.ib(validator=attr.validators.instance_of(int))
+
+    #: Die ausgeschriebenen Lieferstellen
+    lieferstellen: List[Ausschreibungsdetail] = attr.ib(
+        validator=attr.validators.deep_iterable(
+            member_validator=attr.validators.instance_of(Ausschreibungsdetail),
+            iterable_validator=check_list_length_at_least_one,
+        )
+    )
+
+    #: Zeitraum, für den die in diesem Los enthaltenen Lieferstellen beliefert werden sollen
+    lieferzeitraum: Zeitraum = attr.ib(validator=attr.validators.instance_of(Zeitraum))
+
+    # optional attributes
+    #: Bemerkung des Kunden zum Los
+    bemerkung: Optional[str] = attr.ib(
+        validator=attr.validators.optional(attr.validators.instance_of(str)), default=None
+    )
+    #: Gibt den Gesamtjahresverbrauch (z.B. in kWh) aller in diesem Los enthaltenen Lieferstellen an
+    gesamt_menge: Optional[Menge] = attr.ib(
+        validator=attr.validators.optional(attr.validators.instance_of(Menge)), default=None
+    )
+    #: Mindesmenge Toleranzband (kWh, %)
+    wunsch_mindestmenge: Optional[Menge] = attr.ib(
+        validator=attr.validators.optional(attr.validators.instance_of(Menge)), default=None
+    )
+    #: Maximalmenge Toleranzband (kWh, %)
+    wunsch_maximalmenge: Optional[Menge] = attr.ib(
+        validator=attr.validators.optional(attr.validators.instance_of(Menge)), default=None
+    )
+
+    wiederholungsintervall: Optional[Zeitraum] = attr.ib(
+        validator=attr.validators.optional(attr.validators.instance_of(Zeitraum)), default=None
+    )
+    """
+    In welchem Intervall die Angebotsabgabe wiederholt werden darf.
+    Angabe nur gesetzt für die 2. Phase bei öffentlich-rechtlichen Ausschreibungen
+    """
+
+    #: Kundenwunsch zur Kündigungsfrist in der Ausschreibung
+    wunsch_kuendingungsfrist: Optional[Zeitraum] = attr.ib(
+        validator=attr.validators.optional(attr.validators.instance_of(Zeitraum)), default=None
+    )
+    #: Kundenwunsch zum Zahlungsziel in der Ausschreibung
+    wunsch_zahlungsziel: Optional[Zeitraum] = attr.ib(
+        validator=attr.validators.optional(attr.validators.instance_of(Zeitraum)), default=None
+    )
+
+
+class AusschreibungslosSchema(COMSchema):
+    """
+    Schema for de-/serialization of Ausschreibungslos.
+    """
+
+    class_name = Ausschreibungslos
+
+    # required attributes
+    losnummer = fields.String()
+    bezeichnung = fields.String()
+    preismodell = EnumField(Preismodell)
+    energieart = EnumField(Sparte)
+    wunsch_rechnungslegung = EnumField(Rechnungslegung)
+    wunsch_vertragsform = EnumField(Vertragsform)
+    betreut_durch = fields.String()
+    anzahl_lieferstellen = fields.Integer()
+    lieferstellen = fields.List(fields.Nested(AusschreibungsdetailSchema))
+    lieferzeitraum = fields.Nested(ZeitraumSchema)
+
+    # optional attributes
+    bemerkung = fields.String(load_default=None)
+    gesamt_menge = fields.Nested(MengeSchema, load_default=None)
+    wunsch_mindestmenge = fields.Nested(MengeSchema, load_default=None)
+    wunsch_maximalmenge = fields.Nested(MengeSchema, load_default=None)
+    wiederholungsintervall = fields.Nested(ZeitraumSchema, load_default=None)
+    wunsch_kuendingungsfrist = fields.Nested(ZeitraumSchema, load_default=None)
+    wunsch_zahlungsziel = fields.Nested(ZeitraumSchema, load_default=None)


Test patch:
diff --git a/tests/test_ausschreibungsdetail.py b/tests/test_ausschreibungsdetail.py
index 80d72f700..2cb603d37 100644
--- a/tests/test_ausschreibungsdetail.py
+++ b/tests/test_ausschreibungsdetail.py
@@ -12,6 +12,57 @@
 from tests.test_menge import example_menge  # type:ignore[import]
 from tests.test_zeitraum import example_zeitraum  # type:ignore[import]
 
+example_ausschreibungsdetail = Ausschreibungsdetail(
+    lokations_id="56789012345",
+    netzebene_lieferung=Netzebene.MSP,
+    netzebene_messung=Netzebene.NSP,
+    lokationsadresse=example_adresse,
+    lieferzeitraum=example_zeitraum,
+    rechnungsadresse=example_adresse,
+)
+example_ausschreibungsdetail_dict = {
+    "zaehlernummer": None,
+    "zaehlertechnik": None,
+    "kunde": None,
+    "lokationsbezeichnung": None,
+    "lieferzeitraum": {
+        "dauer": "5",
+        "startdatum": None,
+        "endzeitpunkt": None,
+        "enddatum": None,
+        "einheit": "TAG",
+        "startzeitpunkt": None,
+    },
+    "lokationsadresse": {
+        "landescode": "DE",
+        "hausnummer": "27A",
+        "strasse": "Nördliche Münchner Straße",
+        "postleitzahl": "82031",
+        "ort": "Grünwald",
+        "adresszusatz": None,
+        "postfach": None,
+        "coErgaenzung": None,
+    },
+    "rechnungsadresse": {
+        "landescode": "DE",
+        "hausnummer": "27A",
+        "strasse": "Nördliche Münchner Straße",
+        "postleitzahl": "82031",
+        "ort": "Grünwald",
+        "adresszusatz": None,
+        "postfach": None,
+        "coErgaenzung": None,
+    },
+    "netzbetreiber": None,
+    "netzebeneLieferung": "MSP",
+    "prognoseArbeitLieferzeitraum": None,
+    "netzebeneMessung": "NSP",
+    "prognoseLeistung": None,
+    "lastgangVorhanden": None,
+    "prognoseJahresarbeit": None,
+    "lokationsId": "56789012345",
+}
+
 
 class TestAusschreibungsdetail:
     @pytest.mark.parametrize(
@@ -78,58 +129,7 @@ class TestAusschreibungsdetail:
                     "prognoseArbeitLieferzeitraum": {"wert": "2500", "einheit": "KWH"},
                 },
             ),
-            pytest.param(
-                Ausschreibungsdetail(
-                    lokations_id="56789012345",
-                    netzebene_lieferung=Netzebene.MSP,
-                    netzebene_messung=Netzebene.NSP,
-                    lokationsadresse=example_adresse,
-                    lieferzeitraum=example_zeitraum,
-                    rechnungsadresse=example_adresse,
-                ),
-                {
-                    "zaehlernummer": None,
-                    "zaehlertechnik": None,
-                    "kunde": None,
-                    "lokationsbezeichnung": None,
-                    "lieferzeitraum": {
-                        "dauer": "5",
-                        "startdatum": None,
-                        "endzeitpunkt": None,
-                        "enddatum": None,
-                        "einheit": "TAG",
-                        "startzeitpunkt": None,
-                    },
-                    "lokationsadresse": {
-                        "landescode": "DE",
-                        "hausnummer": "27A",
-                        "strasse": "Nördliche Münchner Straße",
-                        "postleitzahl": "82031",
-                        "ort": "Grünwald",
-                        "adresszusatz": None,
-                        "postfach": None,
-                        "coErgaenzung": None,
-                    },
-                    "rechnungsadresse": {
-                        "landescode": "DE",
-                        "hausnummer": "27A",
-                        "strasse": "Nördliche Münchner Straße",
-                        "postleitzahl": "82031",
-                        "ort": "Grünwald",
-                        "adresszusatz": None,
-                        "postfach": None,
-                        "coErgaenzung": None,
-                    },
-                    "netzbetreiber": None,
-                    "netzebeneLieferung": "MSP",
-                    "prognoseArbeitLieferzeitraum": None,
-                    "netzebeneMessung": "NSP",
-                    "prognoseLeistung": None,
-                    "lastgangVorhanden": None,
-                    "prognoseJahresarbeit": None,
-                    "lokationsId": "56789012345",
-                },
-            ),
+            pytest.param(example_ausschreibungsdetail, example_ausschreibungsdetail_dict),
         ],
     )
     def test_serialization_roundtrip(self, ausschreibungsdetail: Ausschreibungsdetail, expected_json_dict: dict):
diff --git a/tests/test_ausschreibungslos.py b/tests/test_ausschreibungslos.py
new file mode 100644
index 000000000..c16001efc
--- /dev/null
+++ b/tests/test_ausschreibungslos.py
@@ -0,0 +1,92 @@
+import pytest  # type:ignore[import]
+
+from bo4e.com.ausschreibungslos import Ausschreibungslos, AusschreibungslosSchema
+from bo4e.enum.preismodell import Preismodell
+from bo4e.enum.rechnungslegung import Rechnungslegung
+from bo4e.enum.sparte import Sparte
+from bo4e.enum.vertragsform import Vertragsform
+from tests.serialization_helper import assert_serialization_roundtrip  # type:ignore[import]
+from tests.test_ausschreibungsdetail import (  # type:ignore[import]
+    example_ausschreibungsdetail,
+    example_ausschreibungsdetail_dict,
+)
+from tests.test_menge import example_menge, example_menge_dict  # type:ignore[import]
+from tests.test_zeitraum import example_zeitraum, example_zeitraum_dict  # type:ignore[import]
+
+
+class TestAusschreibungslos:
+    @pytest.mark.parametrize(
+        "ausschreibungslos, expected_json_dict",
+        [
+            pytest.param(
+                Ausschreibungslos(
+                    losnummer="foo",
+                    bezeichnung="bar",
+                    bemerkung="asd",
+                    preismodell=Preismodell.FESTPREIS,
+                    energieart=Sparte.STROM,
+                    wunsch_rechnungslegung=Rechnungslegung.MONATSRECHN,
+                    wunsch_vertragsform=Vertragsform.DIREKT,
+                    betreut_durch="Max Mustermann",
+                    anzahl_lieferstellen=17,
+                    lieferstellen=[example_ausschreibungsdetail],
+                    gesamt_menge=example_menge,
+                    wunsch_mindestmenge=example_menge,
+                    wunsch_maximalmenge=example_menge,
+                    lieferzeitraum=example_zeitraum,
+                    wunsch_kuendingungsfrist=example_zeitraum,
+                    wunsch_zahlungsziel=example_zeitraum,
+                    wiederholungsintervall=example_zeitraum,
+                ),
+                {
+                    "lieferzeitraum": example_zeitraum_dict,
+                    "preismodell": "FESTPREIS",
+                    "energieart": "STROM",
+                    "wiederholungsintervall": example_zeitraum_dict,
+                    "bemerkung": "asd",
+                    "bezeichnung": "bar",
+                    "losnummer": "foo",
+                    "anzahlLieferstellen": 17,
+                    "lieferstellen": [example_ausschreibungsdetail_dict],
+                    "wunschKuendingungsfrist": example_zeitraum_dict,
+                    "wunschZahlungsziel": example_zeitraum_dict,
+                    "gesamtMenge": example_menge_dict,
+                    "wunschVertragsform": "DIREKT",
+                    "wunschMaximalmenge": example_menge_dict,
+                    "wunschRechnungslegung": "MONATSRECHN",
+                    "wunschMindestmenge": example_menge_dict,
+                    "betreutDurch": "Max Mustermann",
+                },
+                id="maximal attributes",
+            ),
+        ],
+    )
+    def test_serialization_roundtrip(self, ausschreibungslos, expected_json_dict):
+        """
+        Test de-/serialisation of Ausschreibungslos
+        """
+        assert_serialization_roundtrip(ausschreibungslos, AusschreibungslosSchema(), expected_json_dict)
+
+    def test_ausschreibungslos_lieferstellen_required(self):
+        with pytest.raises(ValueError) as excinfo:
+            _ = Ausschreibungslos(
+                losnummer="foo",
+                bezeichnung="bar",
+                bemerkung="asd",
+                preismodell=Preismodell.FESTPREIS,
+                energieart=Sparte.STROM,
+                wunsch_rechnungslegung=Rechnungslegung.MONATSRECHN,
+                wunsch_vertragsform=Vertragsform.DIREKT,
+                betreut_durch="Max Mustermann",
+                anzahl_lieferstellen=17,
+                lieferzeitraum=example_zeitraum,
+                ## ^^ above is just clutter
+                lieferstellen=[],  # the important line
+            )
+
+        assert "List lieferstellen must not be empty." in str(excinfo.value)
+
+    def test_missing_required_attribute(self):
+        with pytest.raises(TypeError) as excinfo:
+            _ = Ausschreibungslos()
+        assert "missing 10 required" in str(excinfo.value)
diff --git a/tests/test_menge.py b/tests/test_menge.py
index d2b682ebb..5adcce0e1 100644
--- a/tests/test_menge.py
+++ b/tests/test_menge.py
@@ -6,6 +6,8 @@
 from bo4e.enum.mengeneinheit import Mengeneinheit
 
 example_menge = Menge(wert=Decimal(3.41), einheit=Mengeneinheit.MWH)
+# see issue https://github.com/Hochfrequenz/BO4E-python/issues/249
+example_menge_dict = {"wert": "3.410000000000000142108547152020037174224853515625", "einheit": "MWH"}
 
 
 class TestMenge:
diff --git a/tests/test_zeitraum.py b/tests/test_zeitraum.py
index 16c2243e7..0b3052679 100644
--- a/tests/test_zeitraum.py
+++ b/tests/test_zeitraum.py
@@ -10,6 +10,14 @@
     einheit=Zeiteinheit.TAG,
     dauer=Decimal(5),
 )
+example_zeitraum_dict = {
+    "dauer": "5",
+    "startdatum": None,
+    "endzeitpunkt": None,
+    "einheit": "TAG",
+    "enddatum": None,
+    "startzeitpunkt": None,
+}
 
 
 class TestZeitraum: