# Guided Practice: From a Verbal Requirement to a Class Structure<no value>

## What will we learn?

This is a **workshop** lesson. We will take one verbal requirement, and go **together** through the whole process:

1. Reading and marking.
2. Deciding on Classes.
3. Responsibility and data for every Class.
4. Relationships between the Classes.
5. A code skeleton.
6. Implementation.
7. Testing, and fixing the design.

## Why do we need this?

In the OOP project you will build later, **before writing the code you need a Design stage**. This lesson is the general rehearsal: exactly the same process, on a different problem, with the instructor leading.

## Explanation from the basics

### The requirement

> **A system for the gym "KodFit"**
>
> The gym has **members**. Every member has a **name**, a **member number** and a **membership type**: **Basic** (up to 8 visits a month) or **Premium** (unlimited). Premium costs 250 NIS a month and Basic 150.
>
> The gym has **classes** (yoga, spinning...). Every class has a **name**, a **coach**, a **day and time**, and a **limited number of spots**.
>
> A member can **register** for a class if there is room, and if they have not used up their monthly visit quota. A registration counts as a visit. A member can **cancel** a registration, and the visit comes back to them.
>
> The system needs to display: all the classes with the free spots, the classes of a certain member, and the monthly revenue from all the members.

## Syntax

There is no new syntax. We will use everything we learned: Classes, `__init__`, properties, Encapsulation, Inheritance + Polymorphism, Composition and Exceptions of our own.

## First code example

### Step 1 — reading and marking

| Nouns | Verbs | Rules and numbers |
|---|---|---|
| Gym, member, name, member number, membership type, Basic, Premium, class, coach, day and time, spots, registration, visit, revenue | Register, cancel, display | Basic: up to 8 visits, 150 NIS. Premium: unlimited, 250 NIS. Limited spots. Registration = a visit. Cancelling returns a visit |

### Step 2 — what are the Classes?

| Candidate | Decision | Reasoning |
|---|---|---|
| **Member** | ✓ Class `Member` | Identity, data, rules (a quota). |
| **Membership type** | ✓ **A hierarchy**: `Plan` → `BasicPlan`, `PremiumPlan` | Every type has **different behavior** (price, limit), and that is exactly Polymorphism. If "Student" is added in the future, you add a Class. |
| **Class** | ✓ Class `GymClass` | Identity, data, a rule (spots). (`Class` is a problematic name, so `GymClass`.) |
| **Registration** | ✗ Not a separate Class (for now) | It has no data of its own beyond "who and where". It is enough for the class to hold its members. |
| **Gym** | ✓ Class `Gym` | Holds all the members and the classes, and coordinates between them. |
| Coach, day and time, name | Attributes | Values, without behavior. |
| Revenue | Method | Calculated from the sum of the prices. |

### Step 3 — responsibility and data

| Class | Responsibility (one sentence) | Data | Interface |
|---|---|---|---|
| `Plan` | Defines the price and the visit limit of a membership type | — | `monthly_price()`, `visit_limit()` |
| `Member` | Manages the monthly quota of one member | Name, number, plan, visits used | `can_visit()`, `use_visit()`, `return_visit()` |
| `GymClass` | Manages the list of people registered for one class | Name, coach, time, capacity, registered members | `has_room()`, `add()`, `remove()`, `is_registered()` |
| `Gym` | Coordinates registrations and provides reports | Members, classes | `add_member()`, `add_class()`, `register()`, `cancel()`, `schedule()`, `classes_of()`, `monthly_revenue()` |

**An important decision:** where is "is it allowed to register" checked? There are **two** rules: room in the class (belongs to `GymClass`) and the quota (belongs to `Member`). `Gym.register` **coordinates**: it asks both of them, and only if both agree, it makes the changes in both.

### Step 4 — relationships

```text
            Gym
           /    \
   has many      has many
      /              \
  Member  ◄────────  GymClass
     │   (class holds its registered members)
   has-a
     │
    Plan  ◄── is-a ── BasicPlan
          ◄── is-a ── PremiumPlan
```

- `Gym` **has many** `Member` and `GymClass` → Composition (internal dicts).
- `Member` **has a** `Plan` → Composition. (A member **is not** a kind of plan!)
- `BasicPlan` **is a** `Plan` → Inheritance + Polymorphism.
- `GymClass` holds **references** to `Member`, not copies.

### Step 5 — a skeleton

```python
class Plan:
    def monthly_price(self): ...
    def visit_limit(self): ...

class BasicPlan(Plan): ...
class PremiumPlan(Plan): ...

class Member:
    def __init__(self, member_id, name, plan): ...
    def can_visit(self): ...
    def use_visit(self): ...
    def return_visit(self): ...

class GymClass:
    def __init__(self, class_id, name, coach, time, capacity): ...
    def has_room(self): ...
    def add(self, member): ...
    def remove(self, member): ...

class Gym:
    def register(self, member_id, class_id): ...
    def cancel(self, member_id, class_id): ...
    def schedule(self): ...
    def monthly_revenue(self): ...
```

## Breaking down the code line by line

A moment before implementing — **checking the design against the requirement**, line by line:

| Requirement | Who handles it |
|---|---|
| "Basic up to 8 visits, Premium unlimited" | `BasicPlan.visit_limit()` → 8. `PremiumPlan.visit_limit()` → `None` (unlimited). |
| "A price by type" | A polymorphic `monthly_price()`. |
| "Register if there is room and if the quota was not used up" | `Gym.register` → `GymClass.has_room()` + `Member.can_visit()`. |
| "Registration = a visit" | `register` calls `member.use_visit()`. |
| "Cancelling returns a visit" | `cancel` → `class.remove()` + `member.return_visit()`. |
| "Display the classes with free spots" | `Gym.schedule()`. |
| "A member's classes" | `Gym.classes_of(member_id)`. |
| "Monthly revenue" | `Gym.monthly_revenue()` → the sum of `member.plan.monthly_price()`. |

Every line in the requirement "lands" in a Class and a method. **No line without a home, and no Class without a reason.**

## Another example

### Step 6 — implementation. `week-3/gym.py`:

```python
class GymError(Exception):
    pass

class Plan:
    def monthly_price(self):
        raise NotImplementedError

    def visit_limit(self):
        raise NotImplementedError

    def name(self):
        return type(self).__name__.replace("Plan", "")

class BasicPlan(Plan):
    def monthly_price(self):
        return 150

    def visit_limit(self):
        return 8

class PremiumPlan(Plan):
    def monthly_price(self):
        return 250

    def visit_limit(self):
        return None

class Member:
    def __init__(self, member_id, name, plan):
        self.member_id = member_id
        self.name = name
        self.plan = plan
        self._visits_used = 0

    @property
    def visits_used(self):
        return self._visits_used

    def can_visit(self):
        limit = self.plan.visit_limit()
        return limit is None or self._visits_used < limit

    def use_visit(self):
        if not self.can_visit():
            raise GymError(self.name + " reached the monthly limit of " + str(self.plan.visit_limit()))
        self._visits_used = self._visits_used + 1

    def return_visit(self):
        if self._visits_used > 0:
            self._visits_used = self._visits_used - 1

class GymClass:
    def __init__(self, class_id, name, coach, time, capacity):
        if capacity <= 0:
            raise ValueError("capacity must be positive")
        self.class_id = class_id
        self.name = name
        self.coach = coach
        self.time = time
        self.capacity = capacity
        self._members = []

    @property
    def free_spots(self):
        return self.capacity - len(self._members)

    def has_room(self):
        return self.free_spots > 0

    def is_registered(self, member):
        return member in self._members

    def add(self, member):
        if self.is_registered(member):
            raise GymError(member.name + " is already in " + self.name)
        if not self.has_room():
            raise GymError(self.name + " is full")
        self._members.append(member)

    def remove(self, member):
        if not self.is_registered(member):
            raise GymError(member.name + " is not registered to " + self.name)
        self._members.remove(member)

class Gym:
    def __init__(self, name):
        self.name = name
        self._members = {}
        self._classes = {}

    def add_member(self, member):
        self._members[member.member_id] = member

    def add_class(self, gym_class):
        self._classes[gym_class.class_id] = gym_class

    def _member(self, member_id):
        if member_id not in self._members:
            raise GymError("no member " + str(member_id))
        return self._members[member_id]

    def _class(self, class_id):
        if class_id not in self._classes:
            raise GymError("no class " + str(class_id))
        return self._classes[class_id]

    def register(self, member_id, class_id):
        member = self._member(member_id)
        gym_class = self._class(class_id)
        if not member.can_visit():
            raise GymError(member.name + " reached the monthly limit")
        gym_class.add(member)
        member.use_visit()

    def cancel(self, member_id, class_id):
        member = self._member(member_id)
        self._class(class_id).remove(member)
        member.return_visit()

    def schedule(self):
        lines = []
        for gym_class in self._classes.values():
            lines.append(gym_class.time + " " + gym_class.name + " with " + gym_class.coach
                         + " - " + str(gym_class.free_spots) + " free")
        return lines

    def classes_of(self, member_id):
        member = self._member(member_id)
        return [c.name for c in self._classes.values() if c.is_registered(member)]

    def monthly_revenue(self):
        return sum(member.plan.monthly_price() for member in self._members.values())
```

### Step 7 — testing. At the end of the file:

```python
def main():
    gym = Gym("KodFit")
    gym.add_member(Member(1, "Dana", PremiumPlan()))
    gym.add_member(Member(2, "Avi", BasicPlan()))
    gym.add_class(GymClass("Y1", "Yoga", "Noa", "Sun 08:00", 2))
    gym.add_class(GymClass("S1", "Spinning", "Tom", "Mon 19:00", 10))

    gym.register(1, "Y1")
    gym.register(2, "Y1")
    gym.register(1, "S1")

    attempts = [(1, "Y1"), (2, "Y9"), (9, "S1")]
    for member_id, class_id in attempts:
        try:
            gym.register(member_id, class_id)
        except GymError as error:
            print("Rejected:", error)

    gym.cancel(2, "Y1")
    for line in gym.schedule():
        print(line)
    print("Dana's classes:", gym.classes_of(1))
    print("Monthly revenue:", gym.monthly_revenue())

if __name__ == "__main__":
    main()
```

**Output:**

```text
Rejected: Dana is already in Yoga
Rejected: no class Y9
Rejected: no member 9
Sun 08:00 Yoga with Noa - 1 free
Mon 19:00 Spinning with Tom - 9 free
Dana's classes: ['Yoga', 'Spinning']
Monthly revenue: 400
```

### What did the test reveal? — fixing the design

Let's check the quota rule with a Basic member:

```python
gym2 = Gym("Test")
avi = Member(2, "Avi", BasicPlan())
gym2.add_member(avi)
for i in range(9):
    gym2.add_class(GymClass("C" + str(i), "Class " + str(i), "Coach", "Tue", 5))
for i in range(9):
    try:
        gym2.register(2, "C" + str(i))
    except GymError as error:
        print("Rejected:", error)
print("Avi used", avi.visits_used, "visits")
```

```text
Rejected: Avi reached the monthly limit
Avi used 8 visits
```

The rule works. **But** notice the hole: **what if `gym_class.add()` succeeded, and `member.use_visit()` failed?** Then the member is registered for the class, but no visit was used. In our implementation this does not happen, because we checked `can_visit()` **before** `add`. But it shows an important rule: **when a method changes two objects, check all the conditions before changing anything.** Exactly as `transfer_to` in the bank calls `withdraw` (which may fail) **before** `deposit`.

## Common mistake

**Mistake 1: skipping steps 1–4 "because it's obvious to me".** After 200 lines you discover that "registration" should have been somewhere else, and that `Member` and `GymClass` "know" each other in a way that causes double updates. 15 minutes of design save hours.

**Mistake 2: inheritance instead of Composition** — `class PremiumMember(Member)` looks natural, but what happens when a member **upgrades** from Basic to Premium? You cannot "change the Class" of an existing object. With Composition (`member.plan = PremiumPlan()`) it is one line. **What may change during the object's life → Composition.**

**Mistake 3: a rule checked in the wrong place.** If `GymClass.add` also checked the member's quota, the class would have to "know" the rules of the plans. Every Class checks **its own rules**, and `Gym` coordinates.

**Mistake 4: not testing error cases.** The test included IDs that do not exist, a double registration and exceeding the quota. Without them, we would only know that the happy path works.

<!-- exercises:start — generated by scripts/intro6/sync_site_exercises.py; edit scripts/intro6/exercises/ -->
## Practice

The exercises use only what was taught in this lesson and the lessons before it. Try on your own first, and only then open the solution.

**What you practice on this page:** the whole way from a verbal requirement to code — in the example of a scooter rental service. Every exercise is one step of the process: first design, then Class after Class.

**By the end of the page you will know how to:**
- Break a requirement into Classes, rules and relationships before writing code
- Build a small hierarchy: a Parent and a Child that changes only a rate
- Put every rule in the Class that holds its data
- Connect several Classes into one system that works

### Exercise 1: From a requirement to a list of classes

**What you do:** the design stage — without code.

**The requirement:**

> "A scooter rental system: a **user** rents a **scooter** for a **ride**. Every scooter has a number and a battery in percent. You must not rent a scooter with less than 20% battery, or a scooter that is already in use. A ride costs 5 NIS to unlock plus 1 NIS per minute. A **fast scooter** costs 2 NIS per minute."

**The task:**
1. Which Classes are there here? For each — what does it know (Attributes)?
2. Which rules are there, and in which Class should each rule sit?
3. What are the relationships between them: who holds whom (has-a), and who is a kind of whom (is-a)?

**What to submit:** a list of Classes with Attributes, a list of rules, and a list of relationships.

{{< solution >}}
1. **Classes:** `User`, `Scooter`, `FastScooter`, `Ride`, and `RentalService` that manages all of them.
2. **Rules:**
   - No rental below 20% battery.
   - No rental of a scooter that is in use.
   - The price of a ride: 5 + minutes × the rate per minute (1 regular, 2 fast).
3. **Relationships:**
   - `FastScooter` **is a kind of** `Scooter` (is-a) — only the rate is different.
   - `Ride` **holds** a user and a scooter (has-a).
   - `RentalService` **holds** many scooters and users.
{{< /solution >}}

### Exercise 2: Scooter and FastScooter

**What you build:** the scooter — and a ride price that depends on its type.

**What to write:**
1. A Class named `Scooter`, with a number, a battery percentage, and `in_use` that starts as `False`.
2. In `Scooter`: a Class Attribute named `PER_MINUTE` with the value 1, and `price` that receives minutes and returns 5 plus the rate per minute times the minutes.
3. A Class named `FastScooter` that inherits from `Scooter`, and changes only `PER_MINUTE` to 2.

**Test code:**

```python
print(Scooter(1, 80).price(10))
print(FastScooter(2, 90).price(10))
```

**The desired output:**

```text
15
25
```

{{< solution >}}
```python
UNLOCK_FEE = 5


class Scooter:
    PER_MINUTE = 1

    def __init__(self, number, battery):
        self.number = number
        self.battery = battery
        self.in_use = False

    def price(self, minutes):
        return UNLOCK_FEE + minutes * self.PER_MINUTE


class FastScooter(Scooter):
    PER_MINUTE = 2


print(Scooter(1, 80).price(10))
print(FastScooter(2, 90).price(10))
```

```text
15
25
```

`self.PER_MINUTE` reads the value from the **object's** Class — so `FastScooter` uses 2 without overriding `price` at all.
{{< /solution >}}

### Exercise 3: The rules in one place

**What you build:** continuing `Scooter` — the two rental rules, in one place.

**What to write:**
1. In `Scooter`, add `unlock`.
2. If the battery is below 20%: a `ValueError` with the first message from the output.
3. If the scooter is already in use: a `ValueError` with the second message from the output.
4. Otherwise: mark the scooter as in use.

**Test code:**

```python
s = Scooter(1, 15)
try:
    s.unlock()
except ValueError as error:
    print("error:", error)
s.battery = 60
s.unlock()
print(s.in_use)
try:
    s.unlock()
except ValueError as error:
    print("error:", error)
```

**The desired output:**

```text
error: battery too low: 15%
True
error: scooter 1 is in use
```

{{< solution >}}
```python
UNLOCK_FEE = 5
MIN_BATTERY = 20


class Scooter:
    PER_MINUTE = 1

    def __init__(self, number, battery):
        self.number = number
        self.battery = battery
        self.in_use = False

    def price(self, minutes):
        return UNLOCK_FEE + minutes * self.PER_MINUTE

    def unlock(self):
        if self.battery < MIN_BATTERY:
            raise ValueError("battery too low: " + str(self.battery) + "%")
        if self.in_use:
            raise ValueError("scooter " + str(self.number) + " is in use")
        self.in_use = True


s = Scooter(1, 15)
try:
    s.unlock()
except ValueError as error:
    print("error:", error)
s.battery = 60
s.unlock()
print(s.in_use)
try:
    s.unlock()
except ValueError as error:
    print("error:", error)
```

```text
error: battery too low: 15%
True
error: scooter 1 is in use
```
{{< /solution >}}

### Exercise 4: A ride

**What you build:** a ride — which connects a user to a scooter.

**What to write:**
1. A Class named `Ride`, which receives a user's name and a scooter and holds them.
2. In the `__init__` of `Ride`: call the scooter's `unlock` — the ride opens only if it is allowed.
3. `finish` — receives minutes, releases the scooter and returns the price of the ride.

**Test code:**

```python
scooter = FastScooter(7, 90)
ride = Ride("Dana", scooter)
print(scooter.in_use)
print(ride.finish(12))
print(scooter.in_use)
```

**The desired output:**

```text
True
29
False
```

{{< solution >}}
```python
UNLOCK_FEE = 5
MIN_BATTERY = 20


class Scooter:
    PER_MINUTE = 1

    def __init__(self, number, battery):
        self.number = number
        self.battery = battery
        self.in_use = False

    def price(self, minutes):
        return UNLOCK_FEE + minutes * self.PER_MINUTE

    def unlock(self):
        if self.battery < MIN_BATTERY:
            raise ValueError("battery too low: " + str(self.battery) + "%")
        if self.in_use:
            raise ValueError("scooter " + str(self.number) + " is in use")
        self.in_use = True


class FastScooter(Scooter):
    PER_MINUTE = 2


class Ride:
    def __init__(self, user, scooter):
        scooter.unlock()
        self.user = user
        self.scooter = scooter

    def finish(self, minutes):
        self.scooter.in_use = False
        return self.scooter.price(minutes)


scooter = FastScooter(7, 90)
ride = Ride("Dana", scooter)
print(scooter.in_use)
print(ride.finish(12))
print(scooter.in_use)
```

```text
True
29
False
```

`Ride` does not check the rules by itself — it calls `unlock`, and the rules stay inside `Scooter`.
{{< /solution >}}

### Exercise 5: The rental service

**What you build:** the whole service — it holds all the scooters and knows which ones are free.

**What to write:**
1. A Class named `RentalService`, which holds a dictionary of scooters by number.
2. `add` — adds a scooter to the dictionary.
3. `get` — receives a number and returns the scooter.
4. `available` — returns a list of the numbers of the scooters that can be rented right now: a battery of at least 20%, and not in use.

**Test code:**

```python
service = RentalService()
service.add(Scooter(1, 80))
service.add(Scooter(2, 10))
service.add(FastScooter(3, 70))
Ride("Dana", service.get(3))
print(service.available())
```

**The desired output:**

```text
[1]
```

{{< solution >}}
```python
UNLOCK_FEE = 5
MIN_BATTERY = 20


class Scooter:
    PER_MINUTE = 1

    def __init__(self, number, battery):
        self.number = number
        self.battery = battery
        self.in_use = False

    def can_rent(self):
        return self.battery >= MIN_BATTERY and not self.in_use

    def unlock(self):
        if self.battery < MIN_BATTERY:
            raise ValueError("battery too low: " + str(self.battery) + "%")
        if self.in_use:
            raise ValueError("scooter " + str(self.number) + " is in use")
        self.in_use = True


class FastScooter(Scooter):
    PER_MINUTE = 2


class Ride:
    def __init__(self, user, scooter):
        scooter.unlock()
        self.user = user
        self.scooter = scooter


class RentalService:
    def __init__(self):
        self._scooters = {}

    def add(self, scooter):
        self._scooters[scooter.number] = scooter

    def get(self, number):
        return self._scooters[number]

    def available(self):
        result = []
        for number, scooter in self._scooters.items():
            if scooter.can_rent():
                result.append(number)
        return result


service = RentalService()
service.add(Scooter(1, 80))
service.add(Scooter(2, 10))
service.add(FastScooter(3, 70))
Ride("Dana", service.get(3))
print(service.available())
```

```text
[1]
```

Scooter 2 — a low battery. Scooter 3 — in use. We added `can_rent()` to `Scooter`, so that the service **asks** the scooter and does not check its data by itself.
{{< /solution >}}

<!-- exercises:end -->

## Summary

- The design process: **marking → Classes → responsibility and data → relationships → skeleton → implementation → testing**.
- **Every line in the requirement** should "land" in a Class and a method. **Every Class** needs a reason.
- **Different behavior by type** → a hierarchy + Polymorphism (Plan). **Something that may change during the object's life** → Composition (member.plan).
- Every Class checks **its own rules**, and a coordinating Class (Gym) connects them.
- When an action changes several objects: **check all the conditions before changing anything.**
- A new requirement = an update to the design. And a good design lets you add without breaking.

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

## Official documentation

- [A First Look at Classes](https://docs.python.org/3/tutorial/classes.html#a-first-look-at-classes)
- [Class and Instance Variables](https://docs.python.org/3/tutorial/classes.html#class-and-instance-variables)

<!-- docs:end -->
