class BankAccount:
    "Represents a bank account."
    
    def __init__(self, account_number=None):
        "Initialize or create a new account."
        self.account_number = ''
        self.__balance = 0
        self.holder = None
        self._transactions = []
        
        if account_number is None:
            self._create_new_acconunt()
        else:
            self.account_number = account_number
            self._fetch_account_data(self, account_number)

    def get_balance(self):
        "Return the actual balance."
        return self.__balance
    
    def get_available_amount(self):
        "Return the maximum amount which can be withdrawn."
        rv = self.__balance + self._calculate_overdraft()
        if rv < 0:
            rv = 0
        return rv
        
    def make_deposit(self, amount, text):
        "Add money to this account."
        self.__balance += amount
        self.transactions.append(Transaction(amount, info=text))
        
    def withdraw(self, amount, text):
        "Withdraw amount from this account."
        overdraft = self._calculate_overdraft()
        if self.__balance + overdraft - amount > 0:
            self.__balance -= amount
            self.transactions.append(Transaction(amount * -1, info=text))
            return True
        else:
            raise BalanceTooLowException('You max amount is ...')
        
    def transfer_money(self, target_account, amount, text):
        "Transfer money from this account to target_account."
        overdraft = self._calculate_overdraft()
        if self.__balance + overdraft - amount > 0:
            # start some complecated procedurce which transfers money
            self.transactions.append(Transaction(amount * -1, target=target_account))
        else:
            raise BalanceTooLowException('You max amount is ...')
        
    def _create_new_account(self):
        "Create initial data for new account"
        self.holder = input('Enter name of account holder:')
    
    def _fetch_account_data(self, account_number):
        "Fetch some data from e.g. a database"
        # Missing: the DB code
        self.balance = account_data.get_balance()
        self.holder = account_data.get_holder()

"""
Explanation: Objektorientiere Programmierung: Etwas Theorie
Objektorientierte Programmierung hat drei wesentliche Merkmale, die für die Popularität dieser Art von Programmierung verantwortlich sind:

Kapselung
Vererbung
Polymorphismus

Sehen wir uns diese der Reihe nach an jeweils einem Beispiel an.
Klassenbildung
Klassen sind Abstraktionen von Dingen und Konzepten
Das Entwerfen einer Klasse bedeutet die Modellierung der benötigten
Information.
Wie jede Modellbildung impliziert das Abstraktion und
Reduktion.
Abstraktion
Unter Abstraktion verstehen wir hier das Herausarbeiten von Gemeinsamkeiten
und somit das Erkennen von übergeordneten Kategorien.
Anna und Hans sind unterschiedliche Individuen, die eine für uns interessante
Gemeinsamkeit haben: Sie sind beide Studierende. Indem wir erkannt haben,
dass beide Individuen dieser Kategorie oder Klasse zugeordnet werden können
(unsere
Wahrnehmung tut dies ununterbrochen), haben wir bereits (zumindest implizit)
ein Modell Studierende(r) entwickelt. Dieses muss nun für die Überführung
in ein Computersystem noch genau formalisiert
werden.
Reduktion
Jedes Modell (und eine Klasse ist nichts anderes als ein solches Modell)
stellt eine Vereinfachung dessen dar, was modelliert wird. Für die
Klasse Student müssen wir also überlegen, welche Daten so ein Modell
beschreiben. Wir könnten ein extrem komplexes Modell entwerfen, in dem
ein Student durch tausende Eigenschaften beschrieben wird, von der Schuhgröße
bis zum bevorzugten Urlaubsland. Die eigentliche Kunst ist jedoch,
sich auf die für den konkreten Anwendungsfall benötigten Daten zu beschränken,
gleichzeitig aber auch keine zu vergessen.
Datenmodellierung
Der Schritt der Modellierung darf nicht unterschätzt werden. In dieser
Phase geht es darum, eine klare Vorstellung davon zu bekommen, welche
Objekte im Programm benötigt werden, welche Eigenschaften diese haben
und wie sie mit ihrer Umwelt interagieren (Methoden).
Fehler in der Planungsphase zu erkennen und zu reparieren ist
weniger aufwendig und damit weniger kostenträchtig, als wenn
der Modellierungsfehler erst im halbfertigen Programm entdeckt wird.
In der Praxis zeichnet man hier echte "Konstruktionspläne" z.B. in
Form von UML-Diagrammen. Diese dienen nicht nur als Hilfe beim Nachdenken
über ein Projekt, sondern auch Kommunikationsmittel und zur Dokumentation.
Bei den Unterlagen im Moodle-Kurs gibt es ein eigenes Dokument zum Thema UML (Unified Modeling Language).
Kapselung
Kapselung beschreibt das Prinzip, dass ein Objekt in seinem Aufbau und seiner Funktionsweise sehr komplex sein kann, dass es aber nach außen nur eine vereinfachte Sichtweise, repräsentiert durch Schnittstellen bereitstellt.
Stellen wir uns eine Klasse vor, das ein Bankkonto repräsentiert:
End of explanation
"""


class Product:
    
    def __init__(name, price):
        self.name = name
        self.price = price
        
    def get_gross_price(self):
        "Return gross price (with tax)."
        return self.prince  + self.price / 100 * 20

"""
Explanation: Das ist nun relativ viel Code. Wesentlich aus der Sicht der Kapselung sind hier zwei Dinge:
Datenkapselung
Der Kontostand (__balance) darf von außen nicht direkt verändert werden, sondern nur über bestimmte Methoden:
* `make_deposit()`
* `withdraw()`
* `transfer_money()`

Dadurch wird sichergestellt, dass niemand ungeprüft den Kontostand verändern kann 
(also z.B. mehr abheben als erlaubt ist). Außerdem können wir in den Methoden 
Code haben, der z.B. jede Transaktion protokolliert.
Kapselung der Funktionalität
Ein Programmierer der mit so einem Account-Objekt interagieren will, 
braucht über die Funktionsweise des Objekts faktisch nichts zu wissen, sondern muss nur die Methoden kennen, die nach außen verfügbar sind:
* `get_balance()`
* `make_deposit()`
* `withdraw()`
* `transfer_money()`

Sollen 1000 Euro auf das Konto überwiesen werden reicht diese Codezeile
~~~
make_deposit(1000, 'Erfolgsprämie für ...')
~~~
Soll Geld auf ein anderes Konto überwiesen werden, kann dies ebenfalls mit einer Zeile erledigt werden:
~~~
transfer_money('AT01234711223322', 1200, 'Miete für Dezember') 
~~~
Um die Details kümmert sich jeweils das Objekt selbst.
Die Kapselung der Funktionalität bietet einen weiteren Vorteil: So lange die Schnittstelle der Methode (d.h. ihr Name, die Parameter und der Rückgabewert) sich nicht verändern, kann der Funktionskörper jederzeit verändert werden, ohne dass Programme, die das Objekt nutzen, umgeschrieben werden müssen.  Hier ein Beispiel:
End of explanation
"""


class MaxBorrowingsError(Exception): pass
class BookNotBorrowedException(Exception): pass

class User:
    
    def __init__(self, firstname, lastname):
        self.max_books = 10
        self.firstname = firstname
        self.lastname = lastname
        self.borrowed_books = []
        
    def borrow_book(self, book):
        if len(self.borrowed_books) < self.max_books:
            self.borrowed_books.append(book)
        else:
            raise MaxBorrowingsError('You have exceeded the number of books you are '
                                     'allowed to borrow.')
            
    def return_book(self, book):
        if book in self.borrowed_books:
            self.borrowed_books.remove(book)
        else:
            raise BookNotBorrowedException('You did not borrow this book!')

"""
Explanation: Sollte sich die Mehrwertsteuer ändern, kann dies in der Methode get_full_price() geändert werden. Existierender Code, der Objekte vom Typ Product nutzt, braucht nicht verändert zu werden. Viel wichtiger kann das natürlich werden, wenn sich zum Beispiel die Art verändert, wie Banken Überweisungen durchführen: man ändert den Körper der Methode transfer_money(), aus der Sicht eines Programms, das Account-Objekte nutzt, ändert sich nichts.
Vererbung
Unter diesem Begriff versteht man das Prinzip, das von einer Klasse jederzeit eine spezialisierte Unterform abgeleitet werden kann. Es entsteht so eine Hierarchie von Typen. Nehmen wir an, wir wollen für die Universitätsbibliothek eine Benutzerverwaltung schreiben, in der es unterschiedlichen Typen von Benutzern gibt. Zunächst erstellen wir einen Basistyp User:
End of explanation
"""


class Student(User):
    
    def __init__(self, firstname, lastname, matrikelnummer):
        self.max_books = 10
        self.firstname = firstname
        self.lastname = lastname
        self.matrikelnummer = matrikelnummer
        self.borrowed_books = []

"""
Explanation: Ein Student ist ein spezieller User, der zusätzlich noch eine Matrikellnummer hat:
End of explanation
"""


otto = Student('Otto', 'Huber', '017844556')
otto.borrow_book('A Book')
print(otto.borrowed_books)
otto.return_book('A Book')
print(otto.borrowed_books)

"""
Explanation: Hier sind 2 Dinge zu beachten:

class Student(User) legt fest, dass die neue Klasse Student einen Spezialfall der
Klasse User darstellt.
Wir überschreiben (oder besser: überlagern) die Methode __init__(). Alle anderen Methoden erbt Student von der Klasse User.
End of explanation
"""


type(otto)

"""
Explanation: Wir sehen hier, dass die Methoden borrow_books() und return_books() zur Verfügung stehen, obwohl wir sie beim Schreiben der Klasse Student gar nicht definiert haben. Sie kommen von der Basisklasse User. Dieser Mechanismus ist sehr mächtig, weil wir nur die Teile einer Klasse zu verändern brauchen, die den Spezialfall der Elternklasse manifestieren.
Auf Typen testen
Wir haben schon mehrfach mit der Funktion type() den Typ eines Wertes abgefragt. Das funktioniert auch mit selbst geschriebenen Klassen, die ja eigene Typen festlegen:
End of explanation
"""


type(otto) is Student

isinstance(otto, Student)

"""
Explanation: Wir können auch auf einen bestimmten Typen testen:
End of explanation
"""


isinstance(otto, User)

"""
Explanation: Mit isinstance() können wir sogar testen, ob ein Wert einen bestimmten Typus hat, der weiter oben in der Vererbungskette steht:
End of explanation
"""


class User2:
    
    def __init__(self, firstname, lastname):
        self.max_books = 10
        self.firstname = firstname
        self.lastname = lastname
        self.borrowed_books = []

        
class Student2(User2):
    
    def __init__(self, firstname, lastname, matrikelnummer):
        super().__init__(firstname, lastname)
        self.matrikelnummer = matrikelnummer
        
anna = Student2('Anna', 'Meier', '0175546655')        
print(anna.firstname, anna.matrikelnummer)


"""
Explanation: Da Vererbung immer Spezialisierung bedeutet, ist in unserer Typhierarchie ein Student immer auch ein User.
Übung
Schreiben Sie eine weitere Klasse Diplomand, als Spezialfall von User, der sich von einem Student nur dadurch unterscheidet, dass max_books auf 30 steht.
super()
super() ist eine spezielle Methode, die eine Referenz auf die Elternklasse liefert. Das kann nützlich sein, um explizit auf eine Methode der Elternklasse zuzugreifen, auch wenn diese in der Kindklasse überschrieben wird. Hier ein Anwendungsfall:
End of explanation
"""


import math


class Rectangle:
    
    def __init__(self, length, width):
        self.length = length
        self.width = width
        
    def get_area(self):
        return self.length * self.width
    
    
class Circle:
    
    def __init__(self, radius):
        self.radius = radius
        
    def get_area(self):
        return self.radius ** 2 * math.pi
    
rect = Rectangle(60, 40)    
circ = Circle(25)

print(rect.get_area())
print(circ.get_area())

"""
Explanation: Polymorphie
Unter Polymorphie bzw. Vielgestaltigkeit versteht man die Fähigkeit, bestimmte Methoden an den Objekttyp anzupassen. Es geht also darum, dass unterschiedliche Typen gleichnamige und gleich aufzurufende Methoden haben, die aber an den jeweiligen Objekttyp angepasste Dinge tun.
End of explanation
"""


figures = [Rectangle(72, 55), Circle(42), Rectangle(22,19)]
sum([fig.get_area() for fig in figures])

"""
Explanation: Das hat den Vorteil, dass wir die Formen in gleicher Weise verwenden können. Um etwas die Gesamtfläche mehrerer geometrischer Formen zu ermitteln können wir das tun:
End of explanation
"""


