آموزش پایتون, پایتون سطح متوسط

پروژه مدیریت مخاطبان با پایتون؛ ساخت برنامه کامل با JSON و OOP

پروژه مدیریت مخاطبان با پایتون

🗓 آخرین به‌روزرسانی:

در پروژه پایانی دوره پایتون سطح متوسط، یک برنامه مدیریت مخاطبان می‌سازیم که اطلاعات را دریافت، اعتبارسنجی، جست‌وجو، ویرایش و حذف می‌کند و پس از بسته‌شدن برنامه نیز آن‌ها را در فایل JSON نگه می‌دارد. این پروژه ترکیبی از کلاس‌ها، متدها، ماژول‌ها، فایل‌ها، مدیریت خطا، تاریخ‌وزمان و ساختارهای داده است.

این مطلب، درس هجدهم و پایانی دوره رایگان پایتون سطح متوسط است. در درس قبلی سازنده __init__، پارامتر self، متدها و وراثت را یاد گرفتیم. اکنون تمام مهارت‌های اصلی دوره را در یک برنامه واقعی به کار می‌بریم.

قابلیت‌های پروژه مدیریت مخاطبان

  • افزودن مخاطب جدید
  • نمایش همه مخاطبان
  • جست‌وجو براساس نام، شماره، ایمیل یا گروه
  • ویرایش اطلاعات مخاطب
  • حذف مخاطب با تأیید کاربر
  • جلوگیری از ثبت شماره تلفن تکراری
  • اعتبارسنجی نام، شماره تلفن و ایمیل
  • ذخیره اطلاعات در فایل JSON
  • بازیابی خودکار اطلاعات هنگام اجرای برنامه
  • مدیریت فایل خراب یا داده نامعتبر
  • ثبت شناسه یکتا و زمان ایجاد هر مخاطب

مهارت‌هایی که در این پروژه استفاده می‌شوند

مبحثکاربرد در پروژه
لیست و دیکشنرینگهداری مجموعه مخاطبان و تبدیل اطلاعات
توابعساخت منو و رابط خط فرمان
مدیریت خطاکنترل خطاهای فایل و ورودی نامعتبر
فایل JSONذخیره دائمی اطلاعات
ماژول‌هاتقسیم پروژه به چند فایل
pathlibمدیریت مسیر فایل داده
datetimeثبت زمان ایجاد مخاطب در UTC
کلاس و شیءمدل‌سازی مخاطب و دفترچه مخاطبان
classmethodساخت شیء مخاطب از دیکشنری JSON

ساختار پوشه‌های پروژه

یک پوشه با نام contact_manager بسازید و فایل‌ها را به این شکل قرار دهید:

contact_manager/
├── main.py
├── contact.py
├── contact_book.py
└── data/
    └── contacts.json

فایل contacts.json لازم نیست از ابتدا وجود داشته باشد؛ برنامه هنگام اولین ذخیره، پوشه و فایل موردنیاز را می‌سازد.

مرحله اول: طراحی کلاس Contact

فایل contact.py مسئول مدل‌سازی یک مخاطب است. هر مخاطب نام، شماره تلفن، ایمیل، گروه، شناسه یکتا و زمان ایجاد دارد.

from datetime import datetime, timezone
from uuid import uuid4


class Contact:
    def __init__(
        self,
        name,
        phone,
        email="",
        group="عمومی",
        contact_id=None,
        created_at=None,
    ):
        self.name = self.validate_name(name)
        self.phone = self.validate_phone(phone)
        self.email = self.validate_email(email)
        self.group = group.strip() or "عمومی"
        self.id = contact_id or str(uuid4())
        self.created_at = created_at or datetime.now(
            timezone.utc
        ).isoformat(timespec="seconds")

    @staticmethod
    def validate_name(value):
        name = value.strip()

        if len(name) < 2:
            raise ValueError(
                "نام مخاطب باید حداقل دو نویسه داشته باشد."
            )

        return name

    @staticmethod
    def validate_phone(value):
        phone = (
            value.strip()
            .replace(" ", "")
            .replace("-", "")
            .replace("(", "")
            .replace(")", "")
        )

        digits = phone[1:] if phone.startswith("+") else phone

        if not digits.isdigit():
            raise ValueError(
                "شماره تلفن فقط می‌تواند شامل عدد و + ابتدایی باشد."
            )

        if not 7 <= len(digits) <= 15:
            raise ValueError(
                "شماره تلفن باید بین ۷ تا ۱۵ رقم داشته باشد."
            )

        return phone

    @staticmethod
    def validate_email(value):
        email = value.strip().lower()

        if not email:
            return ""

        if (
            "@" not in email
            or email.startswith("@")
            or email.endswith("@")
        ):
            raise ValueError("ساختار ایمیل معتبر نیست.")

        local_part, domain = email.rsplit("@", 1)

        if not local_part or "." not in domain:
            raise ValueError("ساختار ایمیل معتبر نیست.")

        return email

    def to_dict(self):
        return {
            "id": self.id,
            "name": self.name,
            "phone": self.phone,
            "email": self.email,
            "group": self.group,
            "created_at": self.created_at,
        }

    @classmethod
    def from_dict(cls, data):
        return cls(
            name=data["name"],
            phone=data["phone"],
            email=data.get("email", ""),
            group=data.get("group", "عمومی"),
            contact_id=data.get("id"),
            created_at=data.get("created_at"),
        )

    def __str__(self):
        email_text = self.email or "بدون ایمیل"

        return (
            f"[{self.id[:8]}] {self.name} | "
            f"{self.phone} | {email_text} | {self.group}"
        )

تحلیل کلاس Contact

سازنده کلاس تمام مقادیر ورودی را پیش از ذخیره اعتبارسنجی می‌کند. به این ترتیب، یک شیء مخاطب نامعتبر وارد برنامه نمی‌شود.

  • uuid4() برای هر مخاطب یک شناسه یکتا می‌سازد.
  • datetime.now(timezone.utc) زمان ایجاد را به‌صورت UTC ثبت می‌کند.
  • to_dict() شیء را برای ذخیره در JSON به دیکشنری تبدیل می‌کند.
  • from_dict() دیکشنری خوانده‌شده از فایل را دوباره به شیء تبدیل می‌کند.
  • __str__() نمایش خوانای مخاطب را تولید می‌کند.

اعتبارسنجی ایمیل در این پروژه

اعتبارسنجی ایمیل در این نسخه ساده و آموزشی است و فقط ساختار کلی را بررسی می‌کند. تشخیص کامل همه حالت‌های مجاز ایمیل بسیار پیچیده‌تر است. در پروژه‌های واقعی معمولاً از کتابخانه معتبر یا ارسال ایمیل تأیید استفاده می‌شود.

مرحله دوم: ساخت کلاس ContactBook

فایل contact_book.py مدیریت مجموعه مخاطبان و ذخیره‌سازی JSON را برعهده دارد:

import json
from pathlib import Path

from contact import Contact


class ContactBook:
    def __init__(self, file_path="data/contacts.json"):
        self.file_path = Path(file_path)
        self.contacts = []

    def load(self):
        if not self.file_path.exists():
            self.contacts = []
            return

        try:
            with self.file_path.open(
                "r", encoding="utf-8"
            ) as file:
                raw_data = json.load(file)

            if not isinstance(raw_data, list):
                raise ValueError(
                    "ساختار اصلی فایل باید یک لیست باشد."
                )

            loaded_contacts = []

            for index, item in enumerate(raw_data, start=1):
                try:
                    loaded_contacts.append(
                        Contact.from_dict(item)
                    )
                except (KeyError, TypeError, ValueError) as error:
                    print(
                        f"رکورد نامعتبر شماره {index} رد شد: "
                        f"{error}"
                    )

            self.contacts = loaded_contacts

        except json.JSONDecodeError:
            print("فایل JSON خراب یا ناقص است.")
            self.contacts = []
        except (OSError, ValueError) as error:
            print(f"خواندن اطلاعات ممکن نشد: {error}")
            self.contacts = []

    def save(self):
        self.file_path.parent.mkdir(
            parents=True,
            exist_ok=True,
        )

        data = [
            contact.to_dict()
            for contact in self.contacts
        ]

        try:
            with self.file_path.open(
                "w", encoding="utf-8"
            ) as file:
                json.dump(
                    data,
                    file,
                    ensure_ascii=False,
                    indent=2,
                )
        except OSError as error:
            raise OSError(
                f"ذخیره اطلاعات ممکن نشد: {error}"
            ) from error

    def _phone_exists(self, phone, ignored_id=None):
        normalized_phone = Contact.validate_phone(phone)

        return any(
            contact.phone == normalized_phone
            and contact.id != ignored_id
            for contact in self.contacts
        )

    def add_contact(self, name, phone, email="", group="عمومی"):
        contact = Contact(name, phone, email, group)

        if self._phone_exists(contact.phone):
            raise ValueError(
                "مخاطبی با این شماره تلفن وجود دارد."
            )

        self.contacts.append(contact)
        return contact

    def get_all(self):
        return sorted(
            self.contacts,
            key=lambda contact: contact.name.casefold(),
        )

    def find_by_id(self, value):
        value = value.strip().lower()

        matches = [
            contact
            for contact in self.contacts
            if contact.id.lower() == value
            or contact.id.lower().startswith(value)
        ]

        if len(matches) == 1:
            return matches[0]

        return None

    def search(self, query):
        query = query.strip().casefold()

        if not query:
            return []

        results = []

        for contact in self.contacts:
            searchable_values = (
                contact.name,
                contact.phone,
                contact.email,
                contact.group,
            )

            if any(
                query in value.casefold()
                for value in searchable_values
            ):
                results.append(contact)

        return sorted(
            results,
            key=lambda contact: contact.name.casefold(),
        )

    def update_contact(
        self,
        contact_id,
        name=None,
        phone=None,
        email=None,
        group=None,
    ):
        current = self.find_by_id(contact_id)

        if current is None:
            raise ValueError("مخاطب پیدا نشد.")

        new_name = current.name if name is None else name
        new_phone = current.phone if phone is None else phone
        new_email = current.email if email is None else email
        new_group = current.group if group is None else group

        updated = Contact(
            name=new_name,
            phone=new_phone,
            email=new_email,
            group=new_group,
            contact_id=current.id,
            created_at=current.created_at,
        )

        if self._phone_exists(
            updated.phone,
            ignored_id=current.id,
        ):
            raise ValueError(
                "مخاطب دیگری با این شماره وجود دارد."
            )

        index = self.contacts.index(current)
        self.contacts[index] = updated

        return updated

    def delete_contact(self, contact_id):
        contact = self.find_by_id(contact_id)

        if contact is None:
            raise ValueError("مخاطب پیدا نشد.")

        self.contacts.remove(contact)
        return contact

چرا مسئولیت‌ها را در دو کلاس جدا کردیم؟

کلاس Contact فقط مسئول اطلاعات و اعتبار یک مخاطب است. کلاس ContactBook مجموعه مخاطبان، جست‌وجو، جلوگیری از تکرار و ذخیره فایل را مدیریت می‌کند.

این جداسازی باعث می‌شود هر کلاس یک مسئولیت اصلی داشته باشد و تغییر یا آزمایش بخش‌های برنامه ساده‌تر شود.

جست‌وجو با casefold

متد casefold() برای مقایسه متن بدون حساسیت به حروف بزرگ و کوچک استفاده شده است. این متد برای متن‌های بین‌المللی از lower() قوی‌تر است.

شناسه کوتاه مخاطب

شناسه اصلی یک UUID کامل است، اما هنگام نمایش فقط هشت نویسه نخست آن نشان داده می‌شود. متد find_by_id() هم شناسه کامل و هم ابتدای یکتای آن را می‌پذیرد.

اگر چند شناسه با پیشوند واردشده مطابقت داشته باشند، متد نتیجه‌ای برنمی‌گرداند تا مخاطب اشتباهی ویرایش یا حذف نشود.

مرحله سوم: ساخت رابط خط فرمان

فایل main.py منو و ارتباط کاربر با کلاس‌ها را مدیریت می‌کند:

from contact_book import ContactBook


def show_menu():
    print("\n" + "=" * 45)
    print("مدیریت مخاطبان")
    print("=" * 45)
    print("1. افزودن مخاطب")
    print("2. نمایش همه مخاطبان")
    print("3. جست‌وجوی مخاطب")
    print("4. ویرایش مخاطب")
    print("5. حذف مخاطب")
    print("0. خروج")


def print_contacts(contacts):
    if not contacts:
        print("مخاطبی برای نمایش وجود ندارد.")
        return

    print("\n" + "-" * 70)

    for contact in contacts:
        print(contact)

    print("-" * 70)


def add_contact_ui(book):
    print("\nافزودن مخاطب جدید")

    name = input("نام: ").strip()
    phone = input("شماره تلفن: ").strip()
    email = input("ایمیل اختیاری: ").strip()
    group = input("گروه اختیاری: ").strip()

    contact = book.add_contact(
        name=name,
        phone=phone,
        email=email,
        group=group or "عمومی",
    )
    book.save()

    print(f"مخاطب {contact.name} با موفقیت ثبت شد.")


def list_contacts_ui(book):
    print_contacts(book.get_all())


def search_contacts_ui(book):
    query = input(
        "نام، شماره، ایمیل یا گروه را وارد کنید: "
    )
    print_contacts(book.search(query))


def update_contact_ui(book):
    print_contacts(book.get_all())

    contact_id = input(
        "شناسه کامل یا ۸ نویسه نخست مخاطب: "
    ).strip()
    contact = book.find_by_id(contact_id)

    if contact is None:
        raise ValueError(
            "مخاطب پیدا نشد یا شناسه واردشده یکتا نیست."
        )

    print("برای نگه‌داشتن مقدار قبلی، Enter بزنید.")
    print("برای حذف ایمیل، یک خط تیره وارد کنید.")

    name = input(f"نام [{contact.name}]: ").strip()
    phone = input(f"شماره [{contact.phone}]: ").strip()
    email = input(
        f"ایمیل [{contact.email or 'خالی'}]: "
    ).strip()
    group = input(f"گروه [{contact.group}]: ").strip()

    if email == "-":
        email_value = ""
    elif email:
        email_value = email
    else:
        email_value = None

    updated = book.update_contact(
        contact_id=contact.id,
        name=name or None,
        phone=phone or None,
        email=email_value,
        group=group or None,
    )
    book.save()

    print(f"اطلاعات {updated.name} به‌روزرسانی شد.")


def delete_contact_ui(book):
    print_contacts(book.get_all())

    contact_id = input(
        "شناسه کامل یا ۸ نویسه نخست مخاطب: "
    ).strip()
    contact = book.find_by_id(contact_id)

    if contact is None:
        raise ValueError(
            "مخاطب پیدا نشد یا شناسه واردشده یکتا نیست."
        )

    confirmation = input(
        f"حذف {contact.name}؟ (y/n): "
    ).strip().lower()

    if confirmation != "y":
        print("حذف لغو شد.")
        return

    deleted = book.delete_contact(contact.id)
    book.save()

    print(f"مخاطب {deleted.name} حذف شد.")


def main():
    book = ContactBook()
    book.load()

    actions = {
        "1": add_contact_ui,
        "2": list_contacts_ui,
        "3": search_contacts_ui,
        "4": update_contact_ui,
        "5": delete_contact_ui,
    }

    while True:
        show_menu()
        choice = input("انتخاب شما: ").strip()

        if choice == "0":
            print("اطلاعات ذخیره شد. خداحافظ.")
            break

        action = actions.get(choice)

        if action is None:
            print("گزینه نامعتبر است.")
            continue

        try:
            action(book)
        except (ValueError, OSError) as error:
            print(f"خطا: {error}")
        except KeyboardInterrupt:
            print("\nعملیات توسط کاربر لغو شد.")


if __name__ == "__main__":
    main()

اجرای پروژه

ترمینال را در پوشه اصلی پروژه باز و برنامه را اجرا کنید:

python main.py

در بعضی سیستم‌ها باید از یکی از دستورهای زیر استفاده کنید:

py main.py
python3 main.py

نمونه اجرای برنامه

=============================================
مدیریت مخاطبان
=============================================
1. افزودن مخاطب
2. نمایش همه مخاطبان
3. جست‌وجوی مخاطب
4. ویرایش مخاطب
5. حذف مخاطب
0. خروج
انتخاب شما: 1

افزودن مخاطب جدید
نام: سارا احمدی
شماره تلفن: +905551234567
ایمیل اختیاری: sara@example.com
گروه اختیاری: همکاران
مخاطب سارا احمدی با موفقیت ثبت شد.

نمونه فایل contacts.json

پس از افزودن مخاطب، فایل داده تقریباً چنین ساختاری خواهد داشت:

[
  {
    "id": "d8aaf8ca-75de-4b0e-bc8c-037a21ea7adc",
    "name": "سارا احمدی",
    "phone": "+905551234567",
    "email": "sara@example.com",
    "group": "همکاران",
    "created_at": "2026-07-20T08:30:00+00:00"
  }
]

شناسه و زمان واقعی در هر اجرا متفاوت خواهند بود.

جریان اجرای برنامه

  1. تابع main() یک شیء ContactBook می‌سازد.
  2. متد load() اطلاعات قبلی را از JSON بازیابی می‌کند.
  3. منو در یک حلقه نمایش داده می‌شود.
  4. انتخاب کاربر با دیکشنری actions به تابع مناسب متصل می‌شود.
  5. پس از افزودن، ویرایش یا حذف، متد save() اجرا می‌شود.
  6. هنگام خروج، اطلاعات از قبل در فایل ثبت شده‌اند.

چرا پس از هر تغییر ذخیره می‌کنیم؟

اگر فقط هنگام خروج ذخیره کنیم، بسته‌شدن ناگهانی برنامه یا قطع برق می‌تواند تغییرات نشست جاری را از بین ببرد. ذخیره پس از هر عملیات تغییردهنده، ریسک ازدست‌رفتن داده را کاهش می‌دهد.

در برنامه‌های بزرگ‌تر می‌توان از پایگاه داده، تراکنش و ذخیره اتمیک استفاده کرد.

مدیریت فایل JSON خراب

اگر فایل JSON ناقص یا خراب باشد، json.load() خطای JSONDecodeError ایجاد می‌کند. برنامه این خطا را کنترل می‌کند و با فهرست خالی ادامه می‌دهد.

در نسخه حرفه‌ای‌تر بهتر است پیش از جایگزینی اطلاعات، از فایل قبلی نسخه پشتیبان تهیه شود و فایل خراب برای بررسی حفظ گردد.

چرا رکوردهای نامعتبر را جداگانه رد می‌کنیم؟

ممکن است فایل JSON از نظر ساختاری سالم باشد، اما یکی از مخاطبان شماره یا نام نامعتبر داشته باشد. حلقه بارگذاری هر رکورد را جداگانه تبدیل می‌کند؛ بنابراین خراب‌بودن یک رکورد باعث ازدست‌رفتن همه مخاطبان سالم نمی‌شود.

بهبود امنیت و حریم خصوصی

فایل JSON این پروژه رمزگذاری نشده است. هر فردی که به فایل دسترسی داشته باشد می‌تواند شماره‌ها و ایمیل‌ها را بخواند. برای اطلاعات واقعی و حساس باید موارد زیر بررسی شوند:

  • محدودکردن دسترسی فایل در سیستم‌عامل
  • رمزگذاری اطلاعات حساس
  • استفاده از پایگاه داده با کنترل دسترسی
  • تهیه نسخه پشتیبان امن
  • جمع‌آوری حداقل اطلاعات موردنیاز
  • رعایت قوانین حریم خصوصی محل فعالیت

آزمایش‌های ضروری پروژه

پیش از تکمیل پروژه، این سناریوها را بررسی کنید:

  1. افزودن مخاطب معتبر
  2. ثبت نام خالی یا یک‌حرفی
  3. ثبت شماره دارای حروف
  4. ثبت دوباره یک شماره تلفن
  5. ثبت ایمیل نامعتبر
  6. جست‌وجو با بخشی از نام
  7. جست‌وجو با گروه
  8. ویرایش شماره به شماره یک مخاطب دیگر
  9. حذف با شناسه صحیح و غلط
  10. لغو حذف با پاسخ غیر از y
  11. اجرای برنامه بدون وجود فایل JSON
  12. اجرای برنامه با فایل JSON خراب
  13. بستن و اجرای مجدد برای اطمینان از ماندگاری اطلاعات

اشتباهات رایج در پروژه

  • فراموش‌کردن ensure_ascii=False و ناخواناشدن متن فارسی در JSON
  • بازکردن فایل بدون encoding="utf-8"
  • ذخیره مستقیم اشیاء در JSON بدون تبدیل به دیکشنری
  • اعتماد کامل به ورودی کاربر بدون اعتبارسنجی
  • استفاده از نام مخاطب به‌عنوان شناسه یکتا
  • ویرایش یا حذف براساس شماره فهرست متغیر
  • ثبت شماره تکراری
  • قرار دادن تمام کدها در یک تابع بسیار بزرگ
  • مدیریت‌نکردن خطای فایل
  • ذخیره اطلاعات حساس بدون توجه به دسترسی فایل

تمرین‌های توسعه پروژه

  1. مرتب‌سازی مخاطبان براساس گروه یا زمان ایجاد را اضافه کنید.
  2. امکان نمایش فقط مخاطبان یک گروه را بسازید.
  3. خروجی CSV برای انتقال اطلاعات ایجاد کنید.
  4. نسخه پشتیبان خودکار از فایل JSON بسازید.
  5. ویژگی تاریخ آخرین ویرایش را اضافه کنید.
  6. شماره‌های ضروری را به‌عنوان مخاطب محبوب علامت‌گذاری کنید.
  7. آزمایش‌های واحد برای کلاس‌های Contact و ContactBook بنویسید.
  8. ذخیره‌سازی را از JSON به SQLite منتقل کنید.
  9. رابط گرافیکی پروژه را با Tkinter بسازید.
  10. یک API برای مخاطبان با FastAPI طراحی کنید.

معیارهای تکمیل پروژه

پروژه زمانی کامل محسوب می‌شود که:

  • بدون فایل اولیه اجرا شود.
  • مخاطب معتبر را ثبت کند.
  • ورودی نامعتبر را با پیام مناسب رد کند.
  • شماره تکراری را نپذیرد.
  • اطلاعات را پس از اجرای مجدد بازیابی کند.
  • جست‌وجو، ویرایش و حذف به‌درستی کار کنند.
  • کد به فایل‌ها و مسئولیت‌های واضح تقسیم شده باشد.
  • خطاهای متداول باعث بسته‌شدن ناگهانی برنامه نشوند.

جمع‌بندی پروژه

در پروژه مدیریت مخاطبان، یک برنامه کامل خط فرمان ساختیم که اطلاعات را با کلاس‌ها مدل‌سازی می‌کند، با متدها رفتارهای مرتبط را مدیریت می‌کند، داده‌ها را در JSON ذخیره می‌کند و خطاهای فایل و ورودی را کنترل می‌کند.

کلاس Contact مسئول اعتبار و تبدیل اطلاعات هر مخاطب است، کلاس ContactBook مجموعه مخاطبان و عملیات اصلی را مدیریت می‌کند و فایل main.py رابط کاربر را در اختیار دارد. این تقسیم مسئولیت، پایه طراحی برنامه‌های قابل نگهداری و توسعه‌پذیر است.

پایان دوره پایتون سطح متوسط

با تکمیل این پروژه، تمام هجده درس دوره پایتون سطح متوسط را پشت سر گذاشته‌اید. در این دوره با ساختارهای داده پیشرفته‌تر، توابع، مدیریت خطا، فایل‌ها، JSON، ماژول‌ها، محیط مجازی، تاریخ‌وزمان و برنامه‌نویسی شی‌گرا کار کردید.

مرحله بعدی می‌تواند انتخاب یکی از مسیرهای تخصصی مانند توسعه وب با Django یا FastAPI، تحلیل داده، خودکارسازی، ساخت ابزارهای دسکتاپ یا یادگیری عمیق‌تر طراحی نرم‌افزار و آزمایش‌نویسی باشد.

ادامه مسیر یادگیری پایتون

برای یادگیری منظم، آموزش‌های مرتبط زیر را به‌ترتیب دنبال کنید:

📌 همچنین بخوانید: بازار کار پایتون در ایران، دستورهای break، continue و pass در پایتون و ساخت ربات تلگرام با پایتون

می‌خواهی پیشرفتت ذخیره شود؟با ورود یا ثبت‌نام رایگان، درس‌های تمام‌شده را علامت بزن و درصد پیشرفتت را در حساب کاربری ببین.ورود / ثبت‌نام برای ذخیره پیشرفت
آموزش‌های تازه، کد آماده و رفع خطاهر هفته در کانال Dev Source؛ رایگان.
🧭 مسیر یادگیری پایتون از صفر
ماژول 9: برنامه‌نویسی شیءگرا — درس 42 از 44

برای ذخیره پیشرفت در همه دستگاه‌ها و دریافت گواهی پایان مسیر، رایگان ثبت‌نام کن یا وارد شو.

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *