Enable Dark Mode!
how-to-use-the-upload-service-in-odoo-19-with-client-actions.jpg
By: Aswin AK

How to Use the Upload Service in Odoo 19 with Client Actions

Technical Odoo 19 Odoo Enterprises Odoo Community

File uploads are a crucial part of modern web applications, and Odoo 19 provides a powerful and elegant Upload Service to handle this functionality. Whether you're building custom modules, creating client actions, or enhancing existing features, understanding the Upload Service will help you implement robust file upload capabilities with progress tracking, error handling, and a seamless user experience.

In this guide, we'll explore how to leverage the Upload Service in Odoo 19 with a complete, production-ready example that demonstrates all its features.

What is the Upload Service?

The Upload Service in Odoo 19 is a core service that manages file uploads with built-in features including:

  • Progress tracking with visual toast notifications
  • Sequential uploads optimized for bandwidth efficiency
  • Automatic file size validation
  • Error handling with user-friendly notifications
  • WebP format conversion for image compatibility
  • URL-based uploads for remote resources

Complete Client Action Example

Here's a comprehensive client action that demonstrates all Upload Service features in a single component:

Owl Component

/** @odoo-module **/
import { Component, useState, useRef } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
class ComprehensiveUploadAction extends Component {
    static template = "my_module.ComprehensiveUploadAction";
    setup() {
        // Services
        this.upload = useService("upload");
        this.notification = useService("notification");
        this.orm = useService("orm");
        
        // Refs for file inputs
        this.fileInputRef = useRef("fileInput");
        this.imageInputRef = useRef("imageInput");
        
        // Component state
        this.state = useState({
            // Upload tabs
            activeTab: "files", // 'files', 'images', 'url', 'dragdrop'
            
            // Uploaded items
            documents: [],
            images: [],
            
            // Drag and drop
            isDragging: false,
            
            // URL upload
            urlInput: "",
            urlUploading: false,
            
            // Loading states
            loading: false,
            
            // Stats
            totalUploads: 0,
            successfulUploads: 0,
            failedUploads: 0,
        });
        // Load existing attachments
        this.loadAttachments();
    }
    // ========================================
    // 1. BASIC FILE UPLOAD
    // ========================================
    
    async onFileInputChange(ev) {
        const files = ev.target.files;
        if (files.length === 0) return;
        await this.uploadFiles(files, {
            isImage: false,
            targetList: "documents",
        });
        
        // Reset input
        ev.target.value = "";
    }
    // ========================================
    // 2. IMAGE-ONLY UPLOAD
    // ========================================
    
    async onImageInputChange(ev) {
        const files = ev.target.files;
        if (files.length === 0) return;
        // Filter only image files
        const imageFiles = Array.from(files).filter(file => 
            file.type.startsWith('image/')
        );
        if (imageFiles.length === 0) {
            this.notification.add(
                "Please select valid image files (jpg, png, gif, webp, etc.)",
                { type: "warning" }
            );
            ev.target.value = "";
            return;
        }
        if (imageFiles.length < files.length) {
            this.notification.add(
                `${files.length - imageFiles.length} non-image file(s) were skipped`,
                { type: "info" }
            );
        }
        await this.uploadFiles(imageFiles, {
            isImage: true,
            targetList: "images",
        });
        
        // Reset input
        ev.target.value = "";
    }
    // ========================================
    // 3. DRAG AND DROP UPLOAD
    // ========================================
    
    onDragOver(ev) {
        ev.preventDefault();
        ev.stopPropagation();
        this.state.isDragging = true;
    }
    onDragLeave(ev) {
        ev.preventDefault();
        ev.stopPropagation();
        this.state.isDragging = false;
    }
    async onDrop(ev) {
        ev.preventDefault();
        ev.stopPropagation();
        this.state.isDragging = false;
        const files = ev.dataTransfer.files;
        if (files.length === 0) return;
        // Separate images from other files
        const imageFiles = [];
        const documentFiles = [];
        Array.from(files).forEach(file => {
            if (file.type.startsWith('image/')) {
                imageFiles.push(file);
            } else {
                documentFiles.push(file);
            }
        });
        // Upload images
        if (imageFiles.length > 0) {
            await this.uploadFiles(imageFiles, {
                isImage: true,
                targetList: "images",
            });
        }
        // Upload documents
        if (documentFiles.length > 0) {
            await this.uploadFiles(documentFiles, {
                isImage: false,
                targetList: "documents",
            });
        }
    }
    // ========================================
    // 4. URL UPLOAD
    // ========================================
    
    onUrlInputChange(ev) {
        this.state.urlInput = ev.target.value;
    }
    async uploadFromUrl() {
        const url = this.state.urlInput.trim();
        
        if (!url) {
            this.notification.add(
                "Please enter a valid URL",
                { type: "warning" }
            );
            return;
        }
        // Basic URL validation
        try {
            new URL(url);
        } catch (error) {
            this.notification.add(
                "Please enter a valid URL (e.g., https://example.com/file.pdf)",
                { type: "warning" }
            );
            return;
        }
        this.state.urlUploading = true;
        this.state.totalUploads++;
        try {
            const resId = this.getResId();
            
            await this.upload.uploadUrl(
                url,
                {
                    resModel: "res.partner",
                    resId: resId,
                },
                (attachment) => {
                    if (attachment.error) {
                        this.state.failedUploads++;
                        this.notification.add(
                            `Error uploading from URL: ${attachment.error}`,
                            { type: "danger" }
                        );
                    } else {
                        this.state.successfulUploads++;
                        this.notification.add(
                            `Successfully uploaded: ${attachment.name}`,
                            { type: "success" }
                        );
                        
                        // Add to appropriate list
                        const targetList = attachment.mimetype?.startsWith('image/') 
                            ? "images" 
                            : "documents";
                        
                        this.state[targetList].push({
                            id: attachment.id,
                            name: attachment.name,
                            mimetype: attachment.mimetype,
                            url: `/web/content/${attachment.id}?download=true`,
                            imageUrl: attachment.mimetype?.startsWith('image/') 
                                ? `/web/image/${attachment.id}` 
                                : null,
                        });
                    }
                }
            );
            // Clear input on success
            this.state.urlInput = "";
            
        } catch (error) {
            this.state.failedUploads++;
            this.notification.add(
                "Failed to upload from URL. Please check the URL and try again.",
                { type: "danger" }
            );
            console.error("URL upload error:", error);
        } finally {
            this.state.urlUploading = false;
        }
    }
    // ========================================
    // CORE UPLOAD LOGIC
    // ========================================
    
    async uploadFiles(files, { isImage, targetList }) {
        const resId = this.getResId();
        
        this.state.totalUploads += files.length;
        try {
            await this.upload.uploadFiles(
                files,
                {
                    resModel: "res.partner",
                    resId: resId,
                    isImage: isImage,
                },
                (attachment) => {
                    if (attachment.error) {
                        // Handle upload error
                        this.state.failedUploads++;
                        this.notification.add(
                            `Error uploading ${attachment.name}: ${attachment.error}`,
                            { type: "danger" }
                        );
                    } else {
                        // Handle successful upload
                        this.state.successfulUploads++;
                        
                        const uploadedItem = {
                            id: attachment.id,
                            name: attachment.name,
                            mimetype: attachment.mimetype,
                            url: `/web/content/${attachment.id}?download=true`,
                            imageUrl: isImage ? `/web/image/${attachment.id}` : null,
                        };
                        this.state[targetList].push(uploadedItem);
                    }
                }
            );
        } catch (error) {
            this.notification.add(
                "Upload failed. Please try again.",
                { type: "danger" }
            );
            console.error("Upload error:", error);
        }
    }
    // ========================================
    // UTILITY METHODS
    // ========================================
    
    getResId() {
        // Get from context or use 0 for new records
        return this.props.action?.context?.active_id || 0;
    }
    async loadAttachments() {
        this.state.loading = true;
        try {
            const resId = this.getResId();
            
            if (!resId) {
                this.state.loading = false;
                return;
            }
            const attachments = await this.orm.searchRead(
                "ir.attachment",
                [
                    ["res_id", "=", resId],
                    ["res_model", "=", "res.partner"]
                ],
                ["name", "mimetype", "file_size", "create_date"]
            );
            // Separate images from documents
            attachments.forEach(att => {
                const item = {
                    id: att.id,
                    name: att.name,
                    mimetype: att.mimetype,
                    url: `/web/content/${att.id}?download=true`,
                    imageUrl: att.mimetype?.startsWith('image/') 
                        ? `/web/image/${att.id}` 
                        : null,
                };
                if (att.mimetype?.startsWith('image/')) {
                    this.state.images.push(item);
                } else {
                    this.state.documents.push(item);
                }
            });
        } catch (error) {
            console.error("Error loading attachments:", error);
        } finally {
            this.state.loading = false;
        }
    }
    async deleteAttachment(attachmentId, listName) {
        try {
            await this.orm.unlink("ir.attachment", [attachmentId]);
            
            // Remove from state
            const index = this.state[listName].findIndex(item => item.id === attachmentId);
            if (index !== -1) {
                this.state[listName].splice(index, 1);
            }
            
            this.notification.add(
                "Attachment deleted successfully",
                { type: "info" }
            );
        } catch (error) {
            this.notification.add(
                "Failed to delete attachment",
                { type: "danger" }
            );
            console.error("Delete error:", error);
        }
    }
    downloadAttachment(url, filename) {
        const link = document.createElement('a');
        link.href = url;
        link.download = filename;
        link.click();
    }
    switchTab(tab) {
        this.state.activeTab = tab;
    }
    triggerFileInput() {
        this.fileInputRef.el?.click();
    }
    triggerImageInput() {
        this.imageInputRef.el?.click();
    }
    get uploadStats() {
        return {
            total: this.state.totalUploads,
            successful: this.state.successfulUploads,
            failed: this.state.failedUploads,
            successRate: this.state.totalUploads > 0 
                ? Math.round((this.state.successfulUploads / this.state.totalUploads) * 100)
                : 0
        };
    }
}
registry.category("actions").add("comprehensive_upload_action", ComprehensiveUploadAction);

XML Template

<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
    <t t-name="my_module.ComprehensiveUploadAction">
        <div class="o_comprehensive_upload_action h-100">
            
            <!-- Header with Stats -->
            <div class="bg-light border-bottom p-3">
                <div class="container-fluid">
                    <div class="row align-items-center">
                        <div class="col-md-6">
                            <h3 class="mb-0">
                                <i class="fa fa-cloud-upload me-2"/>
                                Upload Manager
                            </h3>
                        </div>
                        <div class="col-md-6">
                            <div class="d-flex justify-content-end gap-3">
                                <div class="text-center">
                                    <div class="fw-bold text-primary fs-4">
                                        <t t-esc="uploadStats.total"/>
                                    </div>
                                    <small class="text-muted">Total</small>
                                </div>
                                <div class="text-center">
                                    <div class="fw-bold text-success fs-4">
                                        <t t-esc="uploadStats.successful"/>
                                    </div>
                                    <small class="text-muted">Success</small>
                                </div>
                                <div class="text-center">
                                    <div class="fw-bold text-danger fs-4">
                                        <t t-esc="uploadStats.failed"/>
                                    </div>
                                    <small class="text-muted">Failed</small>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
            <!-- Navigation Tabs -->
            <ul class="nav nav-tabs px-3 pt-3">
                <li class="nav-item">
                    <a class="nav-link" 
                       t-att-class="state.activeTab === 'files' ? 'active' : ''"
                       t-on-click="() => this.switchTab('files')"
                       href="#">
                        <i class="fa fa-file me-1"/>
                        File Upload
                    </a>
                </li>
                <li class="nav-item">
                    <a class="nav-link"
                       t-att-class="state.activeTab === 'images' ? 'active' : ''"
                       t-on-click="() => this.switchTab('images')"
                       href="#">
                        <i class="fa fa-image me-1"/>
                        Image Upload
                    </a>
                </li>
                <li class="nav-item">
                    <a class="nav-link"
                       t-att-class="state.activeTab === 'url' ? 'active' : ''"
                       t-on-click="() => this.switchTab('url')"
                       href="#">
                        <i class="fa fa-link me-1"/>
                        Upload from URL
                    </a>
                </li>
                <li class="nav-item">
                    <a class="nav-link"
                       t-att-class="state.activeTab === 'dragdrop' ? 'active' : ''"
                       t-on-click="() => this.switchTab('dragdrop')"
                       href="#">
                        <i class="fa fa-hand-pointer-o me-1"/>
                        Drag &amp; Drop
                    </a>
                </li>
            </ul>
            <!-- Tab Content -->
            <div class="p-3" style="height: calc(100% - 150px); overflow-y: auto;">
                
                <!-- TAB 1: Basic File Upload -->
                <div t-if="state.activeTab === 'files'" class="tab-pane-content">
                    <div class="card">
                        <div class="card-body">
                            <h5 class="card-title">Upload Files</h5>
                            <p class="text-muted">
                                Select one or multiple files to upload. The upload service will 
                                process them sequentially, starting with the smallest files.
                            </p>
                            
                            <input 
                                type="file" 
                                multiple="true"
                                t-ref="fileInput"
                                t-on-change="onFileInputChange"
                                class="d-none"
                            />
                            
                            <button class="btn btn-primary" t-on-click="triggerFileInput">
                                <i class="fa fa-upload me-2"/>
                                Choose Files
                            </button>
                        </div>
                    </div>
                    <!-- Uploaded Documents List -->
                    <div class="mt-4" t-if="state.documents.length">
                        <h5>Documents (<t t-esc="state.documents.length"/>)</h5>
                        <div class="list-group">
                            <div t-foreach="state.documents" t-as="doc" t-key="doc.id"
                                 class="list-group-item d-flex justify-content-between align-items-center">
                                <div>
                                    <i class="fa fa-file-o me-2"/>
                                    <strong t-esc="doc.name"/>
                                    <span class="badge bg-secondary ms-2" t-esc="doc.mimetype"/>
                                </div>
                                <div>
                                    <button class="btn btn-sm btn-outline-primary me-2"
                                            t-on-click="() => this.downloadAttachment(doc.url, doc.name)">
                                        <i class="fa fa-download"/>
                                    </button>
                                    <button class="btn btn-sm btn-outline-danger"
                                            t-on-click="() => this.deleteAttachment(doc.id, 'documents')">
                                        <i class="fa fa-trash"/>
                                    </button>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
                <!-- TAB 2: Image Upload -->
                <div t-if="state.activeTab === 'images'" class="tab-pane-content">
                    <div class="card">
                        <div class="card-body">
                            <h5 class="card-title">Upload Images</h5>
                            <p class="text-muted">
                                Upload images (JPG, PNG, GIF, WebP). The service automatically 
                                creates JPEG alternatives for WebP images for better compatibility.
                            </p>
                            
                            <input 
                                type="file" 
                                multiple="true"
                                accept="image/*"
                                t-ref="imageInput"
                                t-on-change="onImageInputChange"
                                class="d-none"
                            />
                            
                            <button class="btn btn-primary" t-on-click="triggerImageInput">
                                <i class="fa fa-image me-2"/>
                                Choose Images
                            </button>
                        </div>
                    </div>
                    <!-- Uploaded Images Gallery -->
                    <div class="mt-4" t-if="state.images.length">
                        <h5>Images (<t t-esc="state.images.length"/>)</h5>
                        <div class="row g-3">
                            <div t-foreach="state.images" t-as="img" t-key="img.id"
                                 class="col-md-3">
                                <div class="card">
                                    <img t-att-src="img.imageUrl" 
                                         class="card-img-top" 
                                         style="height: 200px; object-fit: cover;"
                                         t-att-alt="img.name"/>
                                    <div class="card-body p-2">
                                        <p class="card-text small mb-2" 
                                           style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"
                                           t-esc="img.name"/>
                                        <div class="d-flex gap-1">
                                            <button class="btn btn-sm btn-outline-primary flex-fill"
                                                    t-on-click="() => this.downloadAttachment(img.url, img.name)">
                                                <i class="fa fa-download"/>
                                            </button>
                                            <button class="btn btn-sm btn-outline-danger flex-fill"
                                                    t-on-click="() => this.deleteAttachment(img.id, 'images')">
                                                <i class="fa fa-trash"/>
                                            </button>
                                        </div>
                                    </div>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
                <!-- TAB 3: URL Upload -->
                <div t-if="state.activeTab === 'url'" class="tab-pane-content">
                    <div class="card">
                        <div class="card-body">
                            <h5 class="card-title">Upload from URL</h5>
                            <p class="text-muted">
                                Enter a URL to download and upload a file directly from the internet.
                            </p>
                            
                            <div class="input-group mb-3">
                                <input 
                                    type="url" 
                                    class="form-control"
                                    placeholder="https://example.com/file.pdf"
                                    t-att-value="state.urlInput"
                                    t-on-input="onUrlInputChange"
                                    t-att-disabled="state.urlUploading"
                                />
                                <button 
                                    class="btn btn-primary"
                                    t-on-click="uploadFromUrl"
                                    t-att-disabled="state.urlUploading or !state.urlInput">
                                    <i t-att-class="state.urlUploading ? 'fa fa-spinner fa-spin' : 'fa fa-upload'"/>
                                    <span class="ms-2">
                                        <t t-if="state.urlUploading">Uploading...</t>
                                        <t t-else="">Upload</t>
                                    </span>
                                </button>
                            </div>
                            <div class="alert alert-info" role="alert">
                                <i class="fa fa-info-circle me-2"/>
                                <strong>Tip:</strong> Paste any public file URL to upload it to your account.
                            </div>
                        </div>
                    </div>
                </div>
                <!-- TAB 4: Drag and Drop -->
                <div t-if="state.activeTab === 'dragdrop'" class="tab-pane-content">
                    <div 
                        class="dropzone border border-3 rounded p-5 text-center"
                        t-att-class="state.isDragging ? 'border-primary bg-light' : 'border-dashed'"
                        t-on-dragover="onDragOver"
                        t-on-dragleave="onDragLeave"
                        t-on-drop="onDrop"
                        style="min-height: 300px; display: flex; align-items: center; justify-content: center;">
                        <div>
                            <i class="fa fa-cloud-upload fa-5x text-muted mb-3" 
                               t-att-class="state.isDragging ? 'text-primary' : 'text-muted'"/>
                            <h4 t-if="state.isDragging" class="text-primary">Drop files here!</h4>
                            <h4 t-else="">Drag and Drop Files Here</h4>
                            <p class="text-muted mt-3">
                                or click below to browse
                            </p>
                            <button class="btn btn-outline-primary mt-2" t-on-click="triggerFileInput">
                                <i class="fa fa-folder-open me-2"/>
                                Browse Files
                            </button>
                            
                            <input 
                                type="file" 
                                multiple="true"
                                t-ref="fileInput"
                                t-on-change="onFileInputChange"
                                class="d-none"
                            />
                        </div>
                    </div>
                    <div class="alert alert-info mt-4" role="alert">
                        <i class="fa fa-lightbulb-o me-2"/>
                        <strong>Smart Upload:</strong> The drag and drop area automatically 
                        separates images from documents and uploads them to the appropriate category.
                    </div>
                </div>
            </div>
        </div>
    </t>
</templates>

Python: Client Action Registration

# -*- coding: utf-8 -*-
from odoo import models
class ResPartner(models.Model):
    _inherit = 'res.partner'
    def action_open_upload_manager(self):
        return {
            'type': 'ir.actions.client',
            'tag': 'comprehensive_upload_action',
            'name': 'Upload Manager',
            'context': {'active_id': self.id},
        }

Manifest File

# __manifest__.py
{
    'name': 'Upload Service Demo',
    'version': '19.0.1.0.0',
    'category': 'Tools',
    'summary': 'Comprehensive Upload Service demonstration',
    'depends': ['web', 'base'],
    'assets': {
        'web.assets_backend': [
            'my_module/static/src/components/**/*.js',
            'my_module/static/src/components/**/*.xml',
        ],
    },
    'installable': True,
    'application': False,
}

Key Features Demonstrated

1. Sequential Upload Strategy

Files are uploaded one at a time, starting with the smallest, to optimize bandwidth and provide quick feedback to users.

2. Automatic WebP Conversion

When uploading WebP images, the service automatically creates a JPEG alternative at 75% quality for better compatibility with reports.

3. Progress Tracking

The built-in UploadProgressToast component automatically displays upload progress for all files.

4. Comprehensive Error Handling

Both frontend and backend errors are caught and displayed to users with appropriate notifications.

5. Smart File Categorization

The component automatically separates images from documents based on MIME type.

How to Use

  • Install the module in your Odoo instance
  • Open a partner form and add a button to trigger the action:
<button name="action_open_upload_manager" 
        string="Upload Manager" 
        type="object" 
        class="btn-primary"/>

Test all upload methods: file input, image upload, URL upload, and drag-and-drop

How to Use the Upload Service in Odoo 19 with Client Actions-cybrosys

Best Practices

  1. Always validate file types before upload for better UX
  2. Use the isImage flag appropriately for image uploads
  3. Handle errors gracefully with user-friendly messages
  4. Reset file inputs after upload to allow re-uploading the same file
  5. Provide visual feedback during uploads
  6. Set proper resModel and resId for correct attachment linking

Conclusion

This comprehensive example demonstrates all the capabilities of Odoo 19's Upload Service in a single, production-ready component. You can use this as a starting point for your own upload implementations or extract specific features as needed.

The Upload Service handles the complex parts of file uploading progress tracking, error handling, and optimization, allowing you to focus on building great user experiences.

To read more about How to Configure Client Action in Odoo 18, refer to our blog How to Configure Client Action in Odoo 18.


If you need any assistance in odoo, we are online, please chat with us.



0
Comments



Leave a comment



whatsapp_icon
location

Calicut

Cybrosys Technologies Pvt. Ltd.
Neospace, Kinfra Techno Park
Kakkancherry, Calicut
Kerala, India - 673635

location

Kochi

Cybrosys Technologies Pvt. Ltd.
1st Floor, Thapasya Building,
Infopark, Kakkanad,
Kochi, India - 682030.

location

Bangalore

Cybrosys Techno Solutions
The Estate, 8th Floor,
Dickenson Road,
Bangalore, India - 560042

Send Us A Message