ไฟล์แนบประเภทเนื้อหา

นี่เป็นคำแนะนำแบบทีละขั้นที่ 4 ในชุดคำแนะนำแบบทีละขั้นเกี่ยวกับส่วนเสริมของ Classroom

ในคำแนะนำแบบทีละขั้นนี้ คุณจะได้โต้ตอบกับ Google Classroom API เพื่อสร้างไฟล์แนบ คุณจะกำหนดเส้นทางเพื่อให้ผู้ใช้ดูเนื้อหาของไฟล์แนบได้ ซึ่งมุมมองจะแตกต่างกันไปตามบทบาทของผู้ใช้ในชั้นเรียน คำแนะนำแบบทีละขั้นนี้ครอบคลุมไฟล์แนบที่เป็นประเภทเนื้อหา ซึ่งคุณไม่จำเป็นต้องส่งงานของนักเรียน

ในคำแนะนำแบบทีละขั้นนี้ คุณจะต้องทำดังนี้

  • ดึงข้อมูลและใช้พารามิเตอร์การค้นหาส่วนเสริมต่อไปนี้
    • addOnToken: โทเค็นการให้สิทธิ์ที่ส่งไปยังมุมมองการค้นหาไฟล์แนบ
    • itemId: ตัวระบุที่ไม่ซ้ำกันสำหรับ CourseWork, CourseWorkMaterial หรือประกาศที่ได้รับไฟล์แนบของส่วนเสริม
    • itemType: อาจเป็น "courseWork" "courseWorkMaterials" หรือ "announcement"
    • courseId: ตัวระบุที่ไม่ซ้ำกันสำหรับหลักสูตร Google Classroom ที่ใช้สร้างงาน
    • attachmentId: ตัวระบุที่ไม่ซ้ำกันที่ Google Classroom กำหนดให้ใช้กับไฟล์แนบของส่วนเสริมหลังจากที่สร้าง
  • ใช้พื้นที่เก็บข้อมูลถาวรสำหรับไฟล์แนบประเภทเนื้อหา
  • ระบุเส้นทางในการสร้างไฟล์แนบและใช้ iframe มุมมองของครูและมุมมองของนักเรียน
  • ส่งคำขอต่อไปนี้ไปยัง API ส่วนเสริมของ Google Classroom
    • สร้างไฟล์แนบใหม่
    • รับบริบทของส่วนเสริม ซึ่งจะระบุว่าผู้ใช้ที่เข้าสู่ระบบเป็นนักเรียนหรือครู

เมื่อเสร็จแล้ว คุณสามารถสร้างไฟล์แนบประเภทเนื้อหาในงานผ่าน UI ของ Google Classroom เมื่อเข้าสู่ระบบในฐานะครู นอกจากนี้ครูและนักเรียนในชั้นเรียนยังดูเนื้อหาได้อีกด้วย

เปิดใช้ Classroom API

เรียก Classroom API โดยเริ่มจากขั้นตอนนี้ คุณต้องเปิดใช้ API สำหรับโปรเจ็กต์ Google Cloud ก่อนจึงจะเรียกใช้ได้ ไปที่รายการไลบรารีของ Google Classroom API แล้วเลือกเปิดใช้

จัดการพารามิเตอร์การค้นหาของมุมมองการค้นพบไฟล์แนบ

ตามที่ได้กล่าวไปก่อนหน้านี้ Google Classroom จะส่งพารามิเตอร์การค้นหาเมื่อโหลดมุมมองการค้นพบไฟล์แนบใน iframe ดังนี้

  • courseId: รหัสหลักสูตรปัจจุบันของ Classroom
  • itemId: ตัวระบุที่ไม่ซ้ำกันสำหรับ CourseWork, CourseWorkMaterial หรือประกาศที่ได้รับไฟล์แนบของส่วนเสริม
  • itemType: อาจเป็น "courseWork" "courseWorkMaterials" หรือ "announcement"
  • addOnToken: โทเค็นที่ใช้เพื่อให้สิทธิ์การดำเนินการบางอย่างของส่วนเสริมของ Classroom
  • login_hint: รหัส Google ของผู้ใช้ปัจจุบัน

คำแนะนำแบบทีละขั้นนี้ระบุ courseId, itemId, itemType และ addOnToken เก็บรักษาและส่งต่อข้อมูลเหล่านี้เมื่อออกการเรียกไปยัง Classroom API

เช่นเดียวกับขั้นตอนคำแนะนำแบบทีละขั้นก่อนหน้านี้ ให้จัดเก็บค่าพารามิเตอร์การค้นหาที่ส่งผ่านในเซสชันของเรา เราจำเป็นต้องทำเช่นนั้นเมื่อเปิดมุมมองการค้นพบไฟล์แนบเป็นครั้งแรก เพราะเป็นโอกาสเดียวที่ Classroom จะส่งผ่านพารามิเตอร์การค้นหาเหล่านี้

Python

ไปยังไฟล์เซิร์ฟเวอร์ Flask ที่มีเส้นทางสำหรับมุมมองการค้นพบไฟล์แนบ (attachment-discovery-routes.pyหากทำตามตัวอย่างที่ให้ไว้) ที่ด้านบนของเส้นทาง Landing Page ของส่วนเสริม (/classroom-addon ในตัวอย่างที่เราให้ไว้) ให้ดึงข้อมูลและจัดเก็บพารามิเตอร์การค้นหา courseId, itemId, itemType และ addOnToken

# Retrieve the itemId, courseId, and addOnToken query parameters.
if flask.request.args.get("itemId"):
    flask.session["itemId"] = flask.request.args.get("itemId")
if flask.request.args.get("itemType"):
    flask.session["itemType"] = flask.request.args.get("itemType")
if flask.request.args.get("courseId"):
    flask.session["courseId"] = flask.request.args.get("courseId")
if flask.request.args.get("addOnToken"):
    flask.session["addOnToken"] = flask.request.args.get("addOnToken")

เขียนค่าเหล่านี้ไปยังเซสชันเฉพาะเมื่อมีอยู่เท่านั้น และระบบจะไม่ส่งต่อค่าเหล่านี้อีกหากผู้ใช้กลับไปมุมมองการค้นพบไฟล์แนบในภายหลังโดยไม่ปิด iframe

เพิ่มพื้นที่เก็บข้อมูลถาวรสำหรับไฟล์แนบประเภทเนื้อหา

คุณต้องมีระเบียนในเครื่องของไฟล์แนบที่สร้างขึ้น ซึ่งจะช่วยให้คุณค้นหาเนื้อหาที่ครูเลือกโดยใช้ตัวระบุที่ Classroom มีให้

ตั้งค่าสคีมาฐานข้อมูลสำหรับ Attachment ตัวอย่างที่เราให้ไว้มีไฟล์แนบที่แสดงรูปภาพและคำบรรยายภาพ Attachment ประกอบด้วยแอตทริบิวต์ต่อไปนี้

  • attachment_id: ตัวระบุที่ไม่ซ้ำกันสำหรับไฟล์แนบ มอบหมายโดย Classroom และส่งคืนในคำตอบเมื่อสร้างไฟล์แนบ
  • image_filename: ชื่อไฟล์ในเครื่องของรูปภาพที่จะแสดง
  • image_caption: คำบรรยายที่จะแสดงพร้อมรูปภาพ

Python

ขยายการใช้งาน SQLite และ flask_sqlalchemy จากขั้นตอนก่อนหน้า

ไปยังไฟล์ที่คุณได้กำหนดตารางผู้ใช้ไว้ (models.py หากคุณทำตามตัวอย่างที่ให้ไว้) เพิ่มโค้ดต่อไปนี้ที่ด้านล่างของไฟล์ใต้คลาส User

class Attachment(db.Model):
    # The attachmentId is the unique identifier for the attachment.
    attachment_id = db.Column(db.String(120), primary_key=True)

    # The image filename to store.
    image_filename = db.Column(db.String(120))

    # The image caption to store.
    image_caption = db.Column(db.String(120))

นำเข้าคลาสไฟล์แนบใหม่ลงในไฟล์เซิร์ฟเวอร์ที่มีเส้นทางการจัดการไฟล์แนบ

ตั้งค่าเส้นทางใหม่

เริ่มขั้นตอนคำแนะนำแบบทีละขั้นนี้ด้วยการตั้งค่าหน้าเว็บใหม่ในแอปพลิเคชันของเรา ซึ่งจะช่วยให้ผู้ใช้สร้างและดูเนื้อหาผ่านส่วนเสริมของเราได้

เพิ่มเส้นทางการสร้างไฟล์แนบ

คุณต้องมีหน้าเพื่อให้ครูเลือกเนื้อหาและออกคำขอสร้างไฟล์แนบ ใช้เส้นทาง /attachment-options เพื่อแสดงตัวเลือกเนื้อหา ให้ครูเลือก นอกจากนี้ คุณจะต้องมีเทมเพลตสำหรับหน้าการเลือกเนื้อหาและหน้ายืนยันการสร้าง ตัวอย่างที่เราให้ไว้มีเทมเพลตสำหรับกรณีเหล่านี้และยังอาจแสดงคำขอและคำตอบจาก Classroom API ได้ด้วย

โปรดทราบว่าคุณอาจแก้ไขหน้า Landing Page ของมุมมองการค้นพบไฟล์แนบที่มีอยู่ให้แสดงตัวเลือกเนื้อหาแทนการสร้างหน้า /attachment-options ใหม่ได้ เราขอแนะนำให้สร้างหน้าใหม่ตามวัตถุประสงค์ของแบบฝึกหัดนี้เพื่อให้คุณคงลักษณะการทำงานของ SSO ที่ใช้ในขั้นตอนคำแนะนำแบบทีละขั้นที่ 2 เอาไว้ได้ เช่น การเพิกถอนสิทธิ์ของแอป ซึ่งน่าจะมีประโยชน์ในขณะที่คุณสร้างและทดสอบส่วนเสริม

ครูสามารถเลือกจากชุดรูปภาพสั้นๆ ที่มีคำบรรยายในตัวอย่างที่เราให้ไว้ เราให้ภาพของสถานที่สำคัญที่มีชื่อเสียงมา 4 ภาพ ซึ่งคำบรรยายภาพ มาจากชื่อไฟล์

Python

ในตัวอย่างที่เราให้ไว้ ข้อมูลนี้อยู่ในไฟล์ webapp/attachment_routes.py

@app.route("/attachment-options", methods=["GET", "POST"])
def attachment_options():
    """
    Render the attachment options page from the "attachment-options.html"
    template.

    This page displays a grid of images that the user can select using
    checkboxes.
    """

    # A list of the filenames in the static/images directory.
    image_filenames = os.listdir(os.path.join(app.static_folder, "images"))

    # The image_list_form_builder method creates a form that displays a grid
    # of images, checkboxes, and captions with a Submit button. All images
    # passed in image_filenames will be shown, and the captions will be the
    # title-cased filenames.

    # The form must be built dynamically due to limitations in WTForms. The
    # image_list_form_builder method therefore also returns a list of
    # attribute names in the form, which will be used by the HTML template
    # to properly render the form.
    form, var_names = image_list_form_builder(image_filenames)

    # If the form was submitted, validate the input and create the attachments.
    if form.validate_on_submit():

        # Build a dictionary that maps image filenames to captions.
        # There will be one dictionary entry per selected item in the form.
        filename_caption_pairs = construct_filename_caption_dictionary_list(
            form)

        # Check that the user selected at least one image, then proceed to
        # make requests to the Classroom API.
        if len(filename_caption_pairs) > 0:
            return create_attachments(filename_caption_pairs)
        else:
            return flask.render_template(
                "create-attachment.html",
                message="You didn't select any images.",
                form=form,
                var_names=var_names)

    return flask.render_template(
        "attachment-options.html",
        message=("You've reached the attachment options page. "
                "Select one or more images and click 'Create Attachment'."),
        form=form,
        var_names=var_names,
    )

การดำเนินการนี้จะสร้างหน้า "สร้างไฟล์แนบ" ที่มีลักษณะต่อไปนี้

มุมมองการเลือกเนื้อหาตัวอย่าง Python

ครูสามารถเลือกรูปภาพหลายรูป สร้างไฟล์แนบ 1 ไฟล์สำหรับแต่ละรูปภาพที่ครูเลือกในเมธอด create_attachments

ปัญหาคำขอสร้างไฟล์แนบ

เมื่อทราบแล้วว่าครูต้องการแนบเนื้อหาส่วนใด ให้ออกคำขอไปยัง Classroom API เพื่อสร้างไฟล์แนบในงาน เก็บรายละเอียดไฟล์แนบในฐานข้อมูลหลังจากได้รับการตอบกลับจาก Classroom API

เริ่มต้นด้วยการรับอินสแตนซ์ของบริการ Classroom ดังนี้

Python

ในตัวอย่างที่เราให้ไว้ ข้อมูลนี้อยู่ในไฟล์ webapp/attachment_routes.py

def create_attachments(filename_caption_pairs):
    """
    Create attachments and show an acknowledgement page.

    Args:
        filename_caption_pairs: A dictionary that maps image filenames to
            captions.
    """
    # Get the Google Classroom service.
    classroom_service = googleapiclient.discovery.build(
        serviceName="classroom",
        version="v1",
        credentials=credentials)

ส่งคำขอ CREATE ไปยังปลายทาง courses.courseWork.addOnAttachments สำหรับแต่ละรูปภาพที่ครูเลือก ให้สร้างออบเจ็กต์ AddOnAttachment ก่อน ดังนี้

Python

ในตัวอย่างที่เราให้ไว้ นี่เป็นความต่อเนื่องของเมธอด create_attachments

# Create a new attachment for each image that was selected.
attachment_count = 0
for key, value in filename_caption_pairs.items():
    attachment_count += 1

    # Create a dictionary with values for the AddOnAttachment object fields.
    attachment = {
        # Specifies the route for a teacher user.
        "teacherViewUri": {
            "uri":
                flask.url_for(
                    "load_content_attachment", _scheme='https', _external=True),
        },
        # Specifies the route for a student user.
        "studentViewUri": {
            "uri":
                flask.url_for(
                    "load_content_attachment", _scheme='https', _external=True)
        },
        # The title of the attachment.
        "title": f"Attachment {attachment_count}",
    }

ต้องระบุช่อง teacherViewUri, studentViewUri และ title เป็นอย่างน้อยสำหรับไฟล์แนบแต่ละรายการ teacherViewUri และ studentViewUri เป็นตัวแทนของ URL ที่โหลดเมื่อไฟล์แนบเปิดตามประเภทผู้ใช้ที่เกี่ยวข้อง

ส่งออบเจ็กต์ AddOnAttachment ในเนื้อหาคำขอไปยังปลายทาง addOnAttachments ที่เหมาะสม ใส่ตัวระบุ courseId, itemId, itemType และ addOnToken ในคำขอแต่ละรายการ

Python

ในตัวอย่างที่เราให้ไว้ นี่เป็นความต่อเนื่องของเมธอด create_attachments

# Use the itemType to determine which stream item type the teacher created
match flask.session["itemType"]:
    case "announcements":
        parent = classroom_service.courses().announcements()
    case "courseWorkMaterials":
        parent = classroom_service.courses().courseWorkMaterials()
    case _:
        parent = classroom_service.courses().courseWork()

# Issue a request to create the attachment.
resp = parent.addOnAttachments().create(
    courseId=flask.session["courseId"],
    itemId=flask.session["itemId"],
    addOnToken=flask.session["addOnToken"],
    body=attachment).execute()

สร้างรายการสำหรับไฟล์แนบนี้ในฐานข้อมูลในเครื่องเพื่อให้คุณโหลดเนื้อหาที่ถูกต้องได้ในภายหลัง Classroom จะแสดงผลค่า id ที่ไม่ซ้ำกันในการตอบกลับคำขอสร้าง ดังนั้นโปรดใช้คีย์นี้เป็นคีย์หลักในฐานข้อมูลของเรา โปรดทราบว่า Classroom จะส่งพารามิเตอร์การค้นหา attachmentId เมื่อเปิดมุมมองของครูและนักเรียน

Python

ในตัวอย่างที่เราให้ไว้ นี่เป็นความต่อเนื่องของเมธอด create_attachments

# Store the value by id.
new_attachment = Attachment(
    # The new attachment's unique ID, returned in the CREATE response.
    attachment_id=resp.get("id"),
    image_filename=key,
    image_caption=value)
db.session.add(new_attachment)
db.session.commit()

ในกรณีนี้ ให้พิจารณากำหนดเส้นทางผู้ใช้ไปยังหน้ายืนยันเพื่อให้ทราบว่าผู้ใช้สร้างไฟล์แนบสำเร็จแล้ว

อนุญาตไฟล์แนบจากส่วนเสริม

ตอนนี้เป็นโอกาสที่ดีในการเพิ่มที่อยู่ที่เหมาะสมลงในช่องคำนำหน้า URI ของไฟล์แนบที่อนุญาตในหน้าการกำหนดค่าแอปของ Google Workspace Marketplace SDK ส่วนเสริมจะสร้างไฟล์แนบได้จากคำนำหน้า URI รายการใดรายการหนึ่งในหน้านี้เท่านั้น ซึ่งเป็นมาตรการรักษาความปลอดภัยที่ช่วยลด ความเป็นไปได้ที่จะเกิดการโจมตีแบบแทรกกลางการสื่อสาร

วิธีที่ง่ายที่สุดในการระบุโดเมนระดับบนสุดในช่องนี้ เช่น https://example.com https://localhost:<your port number>/ จะทำงานได้ในกรณีที่คุณใช้เครื่องภายในเป็นเว็บเซิร์ฟเวอร์

เพิ่มเส้นทางสำหรับมุมมองของครูและนักเรียน

มี iframe 4 รายการที่ระบบอาจโหลดส่วนเสริมของ Google Classroom คุณจึงสร้างเฉพาะเส้นทางที่แสดง iframe ของมุมมองการค้นพบไฟล์แนบในขณะนี้ จากนั้นเพิ่มเส้นทางเพื่อแสดง iframe มุมมองของครูและนักเรียนด้วย

ต้องใช้ iframe ของ Teacher View เพื่อแสดงตัวอย่างประสบการณ์ของนักเรียน แต่ทั้งนี้สามารถใส่ข้อมูลเพิ่มเติมหรือฟีเจอร์การแก้ไขได้

มุมมองของนักเรียนคือหน้าที่แสดงต่อนักเรียนแต่ละคนเมื่อเปิดไฟล์แนบของส่วนเสริม

สำหรับวัตถุประสงค์ของแบบฝึกหัดนี้ ให้สร้างเส้นทาง /load-content-attachment เส้นทางเดียวสำหรับใช้มุมมองของทั้งครูและนักเรียน ใช้วิธีการของ Classroom API เพื่อระบุว่าผู้ใช้เป็นครูหรือนักเรียนเมื่อโหลดหน้าเว็บ

Python

ในตัวอย่างที่เราให้ไว้ ข้อมูลนี้อยู่ในไฟล์ webapp/attachment_routes.py

@app.route("/load-content-attachment")
def load_content_attachment():
    """
    Load the attachment for the user's role."""

    # Since this is a landing page for the Teacher and Student View iframes, we
    # need to preserve the incoming query parameters.
    if flask.request.args.get("itemId"):
        flask.session["itemId"] = flask.request.args.get("itemId")
    if flask.request.args.get("itemType"):
        flask.session["itemType"] = flask.request.args.get("itemType")
    if flask.request.args.get("courseId"):
        flask.session["courseId"] = flask.request.args.get("courseId")
    if flask.request.args.get("attachmentId"):
        flask.session["attachmentId"] = flask.request.args.get("attachmentId")

โปรดทราบว่าคุณควรตรวจสอบสิทธิ์ผู้ใช้ในขั้นตอนนี้ คุณควรจัดการพารามิเตอร์การค้นหา login_hint ที่นี่ด้วย และเปลี่ยนเส้นทางผู้ใช้ไปยังขั้นตอนการให้สิทธิ์หากจำเป็น ดูข้อมูลเพิ่มเติมเกี่ยวกับขั้นตอนนี้ได้ในรายละเอียดคำแนะนำการเข้าสู่ระบบที่กล่าวถึงในคำแนะนำแบบทีละขั้นก่อนหน้านี้

จากนั้นส่งคำขอไปยังปลายทาง getAddOnContext ที่ตรงกับประเภทรายการ

Python

ในตัวอย่างที่เราให้ไว้ นี่เป็นการดำเนินการที่ต่อเนื่องมาจากเมธอด load_content_attachment

# Create an instance of the Classroom service.
classroom_service = googleapiclient.discovery.build(
    serviceName="classroom"
    version="v1",
    discoveryServiceUrl=f"https://classroom.googleapis.com/$discovery/rest?labels=ADD_ONS_ALPHA&key={GOOGLE_API_KEY}",
    credentials=credentials)

# Use the itemType to determine which stream item type the teacher created
match flask.session["itemType"]:
    case "announcements":
        parent = classroom_service.courses().announcements()
    case "courseWorkMaterials":
        parent = classroom_service.courses().courseWorkMaterials()
    case _:
        parent = classroom_service.courses().courseWork()

addon_context_response = parent.getAddOnContext(
    courseId=flask.session["courseId"],
    itemId=flask.session["itemId"]).execute()

วิธีนี้จะแสดงข้อมูลเกี่ยวกับบทบาทของผู้ใช้ปัจจุบันในชั้นเรียน เปลี่ยนมุมมองที่แสดงให้ผู้ใช้เห็นตามบทบาท ระบบจะป้อนข้อมูลในช่อง studentContext หรือ teacherContext เพียง 1 ช่องในออบเจ็กต์การตอบกลับ ให้ตรวจสอบข้อมูลเหล่านี้เพื่อระบุวิธีการสื่อสารกับผู้ใช้

ไม่ว่าในกรณีใดก็ตาม ให้ใช้ค่าพารามิเตอร์การค้นหา attachmentId เพื่อให้ทราบว่าจะดึงข้อมูลไฟล์แนบใดจากฐานข้อมูลของเรา ระบบจะระบุพารามิเตอร์การค้นหานี้เมื่อเปิด URI มุมมองของครูหรือนักเรียน

Python

ในตัวอย่างที่เราให้ไว้ นี่เป็นการดำเนินการที่ต่อเนื่องมาจากเมธอด load_content_attachment

# Determine which view we are in by testing the returned context type.
user_context = "student" if addon_context_response.get(
    "studentContext") else "teacher"

# Look up the attachment in the database.
attachment = Attachment.query.get(flask.session["attachmentId"])

# Set the text for the next page depending on the user's role.
message_str = f"I see that you're a {user_context}! "
message_str += (
    f"I've loaded the attachment with ID {attachment.attachment_id}. "
    if user_context == "teacher" else
    "Please enjoy this image of a famous landmark!")

# Show the content with the customized message text.
return flask.render_template(
    "show-content-attachment.html",
    message=message_str,
    image_filename=attachment.image_filename,
    image_caption=attachment.image_caption,
    responses=response_strings)

ทดสอบส่วนเสริม

ทำตามขั้นตอนต่อไปนี้เพื่อทดสอบการสร้างไฟล์แนบ

  • ลงชื่อเข้าใช้ [Google Classroom] ในฐานะผู้ใช้ทดสอบของครู
  • ไปที่แท็บงานของชั้นเรียนและสร้างงานใหม่
  • คลิกปุ่มส่วนเสริมใต้พื้นที่ข้อความ จากนั้นเลือกส่วนเสริม iframe จะเปิดขึ้นและส่วนเสริมจะโหลด URI การตั้งค่าไฟล์แนบที่คุณระบุไว้ในหน้าการกำหนดค่าแอปของ Google Workspace Marketplace SDK
  • เลือกเนื้อหาที่จะแนบไปกับงาน
  • ปิด iframe หลังจากขั้นตอนการสร้างไฟล์แนบเสร็จสมบูรณ์

คุณควรเห็นการ์ดไฟล์แนบปรากฏใน UI การสร้างงานใน Google Classroom คลิกที่การ์ดเพื่อเปิด iframe ของ Teacher View และตรวจสอบว่าไฟล์แนบที่ถูกต้องปรากฏขึ้น คลิกปุ่มมอบหมาย

ทำตามขั้นตอนต่อไปนี้เพื่อทดสอบประสบการณ์ของนักเรียน

  • จากนั้นลงชื่อเข้าใช้ Classroom ในฐานะผู้ใช้การสอบของนักเรียนในชั้นเรียนเดียวกับผู้ใช้การสอบของครู
  • ค้นหางานทดสอบในแท็บงานของชั้นเรียน
  • ขยายงานแล้วคลิกการ์ดไฟล์แนบเพื่อเปิด iframe มุมมองของนักเรียน

ตรวจสอบว่าไฟล์แนบที่ถูกต้องปรากฏสำหรับนักเรียน

ยินดีด้วย คุณก็พร้อมที่จะทำขั้นตอนถัดไปแล้ว นั่นคือการสร้างไฟล์แนบประเภทกิจกรรม